vellum 0.2.9 → 0.2.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/bun.lock +2 -2
- package/package.json +2 -2
- package/scripts/capture-x-graphql.ts +1 -18
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
- package/src/__tests__/call-bridge.test.ts +40 -0
- package/src/__tests__/call-state.test.ts +41 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
- package/src/__tests__/home-base-bootstrap.test.ts +13 -8
- package/src/__tests__/intent-routing.test.ts +2 -5
- package/src/__tests__/ipc-snapshot.test.ts +49 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
- package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
- package/src/__tests__/relay-server.test.ts +55 -0
- package/src/__tests__/skills.test.ts +83 -0
- package/src/__tests__/system-prompt.test.ts +2 -24
- package/src/__tests__/twilio-provider.test.ts +36 -0
- package/src/__tests__/twilio-routes.test.ts +108 -0
- package/src/calls/call-orchestrator.ts +25 -5
- package/src/calls/call-state.ts +23 -0
- package/src/calls/relay-server.ts +56 -1
- package/src/calls/twilio-config.ts +9 -13
- package/src/calls/twilio-provider.ts +6 -1
- package/src/calls/twilio-routes.ts +10 -1
- package/src/cli/core-commands.ts +12 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
- package/src/config/bundled-skills/document/SKILL.md +11 -3
- package/src/config/bundled-skills/followups/icon.svg +24 -0
- package/src/config/bundled-skills/messaging/SKILL.md +7 -3
- package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
- package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +4 -7
- package/src/config/system-prompt.ts +64 -360
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
- package/src/daemon/handlers/config.ts +20 -9
- package/src/daemon/handlers/home-base.ts +3 -2
- package/src/daemon/handlers/identity.ts +127 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/workspace-files.ts +75 -0
- package/src/daemon/ipc-contract-inventory.json +16 -4
- package/src/daemon/ipc-contract.ts +62 -2
- package/src/daemon/lifecycle.ts +16 -0
- package/src/daemon/session-notifiers.ts +29 -0
- package/src/daemon/session-surfaces.ts +5 -2
- package/src/daemon/session-tool-setup.ts +15 -4
- package/src/home-base/bootstrap.ts +3 -1
- package/src/home-base/prebuilt/seed.ts +16 -5
- package/src/inbound/public-ingress-urls.ts +15 -4
- package/src/runtime/http-server.ts +123 -20
- package/src/security/oauth2.ts +19 -161
- package/src/tools/browser/auto-navigate.ts +2 -2
- package/src/tools/browser/x-auto-navigate.ts +1 -1
- package/src/tools/claude-code/claude-code.ts +1 -1
- package/src/tools/system/version.ts +43 -0
- package/src/tools/tasks/work-item-run.ts +1 -1
- package/src/tools/terminal/parser.ts +29 -7
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +9 -2
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* `RUNTIME_HTTP_PORT` is set (default: disabled).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, statfsSync } from 'node:fs';
|
|
9
|
-
import { resolve } from 'node:path';
|
|
8
|
+
import { existsSync, readFileSync, statSync, statfsSync } from 'node:fs';
|
|
9
|
+
import { resolve, join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { timingSafeEqual } from 'node:crypto';
|
|
11
12
|
import { ConfigError, IngressBlockedError } from '../util/errors.js';
|
|
12
13
|
import { getLogger } from '../util/logger.js';
|
|
14
|
+
import { getWorkspacePromptPath } from '../util/platform.js';
|
|
13
15
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
14
16
|
import { loadConfig } from '../config/loader.js';
|
|
15
17
|
import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
|
|
@@ -378,6 +380,7 @@ export class RuntimeHttpServer {
|
|
|
378
380
|
log.info({ callSessionId, code, reason: reason?.toString() }, 'ConversationRelay WebSocket closed');
|
|
379
381
|
if (callSessionId) {
|
|
380
382
|
const connection = activeRelayConnections.get(callSessionId);
|
|
383
|
+
connection?.handleTransportClosed(code, reason?.toString());
|
|
381
384
|
connection?.destroy();
|
|
382
385
|
activeRelayConnections.delete(callSessionId);
|
|
383
386
|
}
|
|
@@ -395,16 +398,9 @@ export class RuntimeHttpServer {
|
|
|
395
398
|
}
|
|
396
399
|
|
|
397
400
|
// Startup guard: log gateway-only mode warnings
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
402
|
-
if (!isLoopbackHost(this.hostname)) {
|
|
403
|
-
log.warn('gateway-only mode is enabled but RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
} catch {
|
|
407
|
-
// Config loading may fail during startup — don't block server start
|
|
401
|
+
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
402
|
+
if (!isLoopbackHost(this.hostname)) {
|
|
403
|
+
log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
408
404
|
}
|
|
409
405
|
|
|
410
406
|
log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
|
|
@@ -445,14 +441,13 @@ export class RuntimeHttpServer {
|
|
|
445
441
|
// WebSocket upgrade for ConversationRelay — before auth check because
|
|
446
442
|
// Twilio WebSocket connections don't use bearer tokens.
|
|
447
443
|
if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
448
|
-
//
|
|
444
|
+
// Only allow relay connections from private network peers.
|
|
449
445
|
// Primary check: actual peer address (cannot be spoofed) — accepts loopback
|
|
450
446
|
// and RFC 1918/4193 private addresses to support container deployments.
|
|
451
447
|
// Secondary check: Origin header (defense in depth).
|
|
452
|
-
|
|
453
|
-
if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
|
|
448
|
+
if (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req)) {
|
|
454
449
|
return Response.json(
|
|
455
|
-
{ error: 'Direct relay access disabled
|
|
450
|
+
{ error: 'Direct relay access disabled — only private network peers allowed', code: 'GATEWAY_ONLY' },
|
|
456
451
|
{ status: 403 },
|
|
457
452
|
);
|
|
458
453
|
}
|
|
@@ -486,11 +481,10 @@ export class RuntimeHttpServer {
|
|
|
486
481
|
if (resolvedTwilioSubpath && req.method === 'POST') {
|
|
487
482
|
const twilioSubpath = resolvedTwilioSubpath;
|
|
488
483
|
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
|
|
484
|
+
// Block direct Twilio webhook routes — must go through the gateway
|
|
485
|
+
if (GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
|
|
492
486
|
return Response.json(
|
|
493
|
-
{ error: 'Direct webhook access disabled
|
|
487
|
+
{ error: 'Direct webhook access disabled. Use the gateway.', code: 'GATEWAY_ONLY' },
|
|
494
488
|
{ status: 410 },
|
|
495
489
|
);
|
|
496
490
|
}
|
|
@@ -619,6 +613,19 @@ export class RuntimeHttpServer {
|
|
|
619
613
|
return this.handleHealth();
|
|
620
614
|
}
|
|
621
615
|
|
|
616
|
+
if (endpoint === 'conversations' && req.method === 'GET') {
|
|
617
|
+
const limit = Number(url.searchParams.get('limit') ?? 50);
|
|
618
|
+
const conversations = conversationStore.listConversations(limit);
|
|
619
|
+
return Response.json({
|
|
620
|
+
sessions: conversations.map((c) => ({
|
|
621
|
+
id: c.id,
|
|
622
|
+
title: c.title ?? 'Untitled',
|
|
623
|
+
updatedAt: c.updatedAt,
|
|
624
|
+
threadType: c.threadType === 'private' ? 'private' : 'standard',
|
|
625
|
+
})),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
622
629
|
if (endpoint === 'messages' && req.method === 'GET') {
|
|
623
630
|
return handleListMessages(url, this.interfacesDir);
|
|
624
631
|
}
|
|
@@ -770,6 +777,10 @@ export class RuntimeHttpServer {
|
|
|
770
777
|
return await handleConnectAction(fakeReq);
|
|
771
778
|
}
|
|
772
779
|
|
|
780
|
+
if (endpoint === 'identity' && req.method === 'GET') {
|
|
781
|
+
return this.handleGetIdentity();
|
|
782
|
+
}
|
|
783
|
+
|
|
773
784
|
if (endpoint === 'events' && req.method === 'GET') {
|
|
774
785
|
return handleSubscribeAssistantEvents(req, url);
|
|
775
786
|
}
|
|
@@ -925,6 +936,98 @@ export class RuntimeHttpServer {
|
|
|
925
936
|
}
|
|
926
937
|
}
|
|
927
938
|
|
|
939
|
+
private handleGetIdentity(): Response {
|
|
940
|
+
const identityPath = getWorkspacePromptPath('IDENTITY.md');
|
|
941
|
+
if (!existsSync(identityPath)) {
|
|
942
|
+
return Response.json({ error: 'IDENTITY.md not found' }, { status: 404 });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const content = readFileSync(identityPath, 'utf-8');
|
|
946
|
+
const fields: Record<string, string> = {};
|
|
947
|
+
for (const line of content.split('\n')) {
|
|
948
|
+
const trimmed = line.trim();
|
|
949
|
+
const lower = trimmed.toLowerCase();
|
|
950
|
+
const extract = (prefix: string): string | null => {
|
|
951
|
+
if (!lower.startsWith(prefix)) return null;
|
|
952
|
+
return trimmed.split(':**').pop()?.trim() ?? null;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const name = extract('- **name:**');
|
|
956
|
+
if (name) { fields.name = name; continue; }
|
|
957
|
+
const role = extract('- **role:**');
|
|
958
|
+
if (role) { fields.role = role; continue; }
|
|
959
|
+
const personality = extract('- **personality:**') ?? extract('- **vibe:**');
|
|
960
|
+
if (personality) { fields.personality = personality; continue; }
|
|
961
|
+
const emoji = extract('- **emoji:**');
|
|
962
|
+
if (emoji) { fields.emoji = emoji; continue; }
|
|
963
|
+
const home = extract('- **home:**');
|
|
964
|
+
if (home) { fields.home = home; continue; }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Read version from package.json
|
|
968
|
+
let version: string | undefined;
|
|
969
|
+
try {
|
|
970
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
971
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
972
|
+
version = pkg.version;
|
|
973
|
+
} catch {
|
|
974
|
+
// ignore
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Read createdAt from IDENTITY.md file birthtime
|
|
978
|
+
let createdAt: string | undefined;
|
|
979
|
+
try {
|
|
980
|
+
const stats = statSync(identityPath);
|
|
981
|
+
createdAt = stats.birthtime.toISOString();
|
|
982
|
+
} catch {
|
|
983
|
+
// ignore
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Read lockfile for assistantId, cloud, and originSystem
|
|
987
|
+
let assistantId: string | undefined;
|
|
988
|
+
let cloud: string | undefined;
|
|
989
|
+
let originSystem: string | undefined;
|
|
990
|
+
try {
|
|
991
|
+
const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
992
|
+
const lockfilePaths = [
|
|
993
|
+
join(homedir, '.vellum.lock.json'),
|
|
994
|
+
join(homedir, '.vellum.lockfile.json'),
|
|
995
|
+
];
|
|
996
|
+
for (const lockPath of lockfilePaths) {
|
|
997
|
+
if (!existsSync(lockPath)) continue;
|
|
998
|
+
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
999
|
+
const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
|
|
1000
|
+
if (assistants && assistants.length > 0) {
|
|
1001
|
+
// Use the most recently hatched assistant
|
|
1002
|
+
const sorted = [...assistants].sort((a, b) => {
|
|
1003
|
+
const dateA = new Date(a.hatchedAt as string || 0).getTime();
|
|
1004
|
+
const dateB = new Date(b.hatchedAt as string || 0).getTime();
|
|
1005
|
+
return dateB - dateA;
|
|
1006
|
+
});
|
|
1007
|
+
const latest = sorted[0];
|
|
1008
|
+
assistantId = latest.assistantId as string | undefined;
|
|
1009
|
+
cloud = latest.cloud as string | undefined;
|
|
1010
|
+
originSystem = cloud === 'local' ? 'local' : cloud;
|
|
1011
|
+
}
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
} catch {
|
|
1015
|
+
// ignore — lockfile may not exist
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return Response.json({
|
|
1019
|
+
name: fields.name ?? '',
|
|
1020
|
+
role: fields.role ?? '',
|
|
1021
|
+
personality: fields.personality ?? '',
|
|
1022
|
+
emoji: fields.emoji ?? '',
|
|
1023
|
+
home: fields.home ?? '',
|
|
1024
|
+
version,
|
|
1025
|
+
assistantId,
|
|
1026
|
+
createdAt,
|
|
1027
|
+
originSystem,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
928
1031
|
private handleHealth(): Response {
|
|
929
1032
|
return Response.json({
|
|
930
1033
|
status: 'healthy',
|
package/src/security/oauth2.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* General-purpose OAuth2 Authorization Code flow with PKCE.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Uses the gateway callback transport: OAuth callbacks route through the
|
|
5
|
+
* gateway's OAuth callback route + in-memory registry (requires
|
|
6
|
+
* ingress.publicBaseUrl to be configured).
|
|
7
7
|
*
|
|
8
8
|
* Moved from integrations/oauth2.ts. Types that were in integrations/types.ts
|
|
9
9
|
* are now inlined here since the integration framework is removed.
|
|
@@ -138,122 +138,6 @@ async function exchangeCodeForTokens(
|
|
|
138
138
|
return { tokens, grantedScopes, rawTokenResponse: tokenData };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
// Transport auto-detection
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Determine which callback transport to use when not explicitly specified.
|
|
147
|
-
* Uses gateway if a public base URL is configured (ingress.publicBaseUrl or
|
|
148
|
-
* INGRESS_PUBLIC_BASE_URL), otherwise loopback.
|
|
149
|
-
*/
|
|
150
|
-
function detectTransport(): 'loopback' | 'gateway' {
|
|
151
|
-
try {
|
|
152
|
-
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
153
|
-
const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
154
|
-
const appConfig = loadConfig();
|
|
155
|
-
getPublicBaseUrl(appConfig); // throws if no public URL configured
|
|
156
|
-
return 'gateway';
|
|
157
|
-
} catch {
|
|
158
|
-
log.debug('No public base URL configured for transport auto-detection, defaulting to loopback');
|
|
159
|
-
}
|
|
160
|
-
return 'loopback';
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
// Loopback transport
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
|
|
167
|
-
async function runLoopbackFlow(
|
|
168
|
-
config: OAuth2Config,
|
|
169
|
-
callbacks: OAuth2FlowCallbacks,
|
|
170
|
-
codeVerifier: string,
|
|
171
|
-
codeChallenge: string,
|
|
172
|
-
state: string,
|
|
173
|
-
): Promise<OAuth2FlowResult> {
|
|
174
|
-
let resolveCode: (value: { code: string; returnedState: string }) => void;
|
|
175
|
-
let rejectCode: (reason: Error) => void;
|
|
176
|
-
|
|
177
|
-
const codePromise = new Promise<{ code: string; returnedState: string }>((resolve, reject) => {
|
|
178
|
-
resolveCode = resolve;
|
|
179
|
-
rejectCode = reject;
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const FLOW_TIMEOUT_MS = 120_000;
|
|
183
|
-
|
|
184
|
-
const timeout = setTimeout(() => {
|
|
185
|
-
rejectCode(new Error('OAuth2 flow timed out waiting for user authorization'));
|
|
186
|
-
}, FLOW_TIMEOUT_MS);
|
|
187
|
-
|
|
188
|
-
const server = Bun.serve({
|
|
189
|
-
hostname: '127.0.0.1',
|
|
190
|
-
port: 0,
|
|
191
|
-
fetch(req) {
|
|
192
|
-
const url = new URL(req.url);
|
|
193
|
-
if (url.pathname !== '/callback') {
|
|
194
|
-
return new Response('Not found', { status: 404 });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const error = url.searchParams.get('error');
|
|
198
|
-
if (error) {
|
|
199
|
-
const desc = url.searchParams.get('error_description') ?? error;
|
|
200
|
-
rejectCode(new Error(`OAuth2 authorization denied: ${desc}`));
|
|
201
|
-
return new Response(
|
|
202
|
-
'<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>',
|
|
203
|
-
{ headers: { 'Content-Type': 'text/html' } },
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const code = url.searchParams.get('code');
|
|
208
|
-
const returnedState = url.searchParams.get('state');
|
|
209
|
-
if (!code || !returnedState) {
|
|
210
|
-
rejectCode(new Error('OAuth2 callback missing code or state'));
|
|
211
|
-
return new Response('Missing code or state', { status: 400 });
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (returnedState !== state) {
|
|
215
|
-
rejectCode(new Error('OAuth2 state mismatch — possible CSRF attack'));
|
|
216
|
-
return new Response('State mismatch', { status: 400 });
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
resolveCode({ code, returnedState });
|
|
220
|
-
return new Response(
|
|
221
|
-
'<html><body><h2>Authorization successful!</h2><p>You can close this tab and return to Vellum.</p></body></html>',
|
|
222
|
-
{ headers: { 'Content-Type': 'text/html' } },
|
|
223
|
-
);
|
|
224
|
-
},
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
const usePKCE = !config.clientSecret;
|
|
231
|
-
const authParams = new URLSearchParams({
|
|
232
|
-
...config.extraParams,
|
|
233
|
-
client_id: config.clientId,
|
|
234
|
-
redirect_uri: redirectUri,
|
|
235
|
-
response_type: 'code',
|
|
236
|
-
scope: config.scopes.join(' '),
|
|
237
|
-
state,
|
|
238
|
-
...(usePKCE ? { code_challenge: codeChallenge, code_challenge_method: 'S256' } : {}),
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const authUrl = `${config.authUrl}?${authParams}`;
|
|
242
|
-
callbacks.openUrl(authUrl);
|
|
243
|
-
|
|
244
|
-
const { code, returnedState } = await codePromise;
|
|
245
|
-
|
|
246
|
-
if (returnedState !== state) {
|
|
247
|
-
throw new Error('OAuth2 state mismatch — possible CSRF attack');
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
251
|
-
} finally {
|
|
252
|
-
clearTimeout(timeout);
|
|
253
|
-
server.stop(true);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
141
|
// ---------------------------------------------------------------------------
|
|
258
142
|
// Gateway transport
|
|
259
143
|
// ---------------------------------------------------------------------------
|
|
@@ -302,12 +186,9 @@ async function runGatewayFlow(
|
|
|
302
186
|
/**
|
|
303
187
|
* Run a full OAuth2 authorization code flow with PKCE support.
|
|
304
188
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* Transport is auto-detected based on ingress.publicBaseUrl config unless
|
|
310
|
-
* explicitly specified via options.callbackTransport.
|
|
189
|
+
* Uses the gateway callback transport, which routes OAuth callbacks through
|
|
190
|
+
* the gateway's OAuth route + in-memory registry. Requires a public ingress
|
|
191
|
+
* URL to be configured.
|
|
311
192
|
*/
|
|
312
193
|
export async function startOAuth2Flow(
|
|
313
194
|
config: OAuth2Config,
|
|
@@ -318,49 +199,26 @@ export async function startOAuth2Flow(
|
|
|
318
199
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
319
200
|
const state = generateState();
|
|
320
201
|
|
|
321
|
-
//
|
|
322
|
-
let
|
|
202
|
+
// Always enforce gateway transport and require a public ingress URL
|
|
203
|
+
let hasPublicUrl = false;
|
|
323
204
|
try {
|
|
324
205
|
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
325
|
-
|
|
206
|
+
const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
207
|
+
getPublicBaseUrl(loadConfig());
|
|
208
|
+
hasPublicUrl = true;
|
|
326
209
|
} catch {
|
|
327
|
-
//
|
|
328
|
-
// most restrictive mode to prevent loopback fallback from creating a fail-open path.
|
|
329
|
-
log.warn('Failed to load config for OAuth ingress mode detection; defaulting to gateway_only (fail closed)');
|
|
330
|
-
ingressMode = 'gateway_only';
|
|
210
|
+
// No public URL configured
|
|
331
211
|
}
|
|
332
212
|
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
338
|
-
const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
339
|
-
getPublicBaseUrl(loadConfig());
|
|
340
|
-
hasPublicUrl = true;
|
|
341
|
-
} catch {
|
|
342
|
-
// No public URL configured
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (!hasPublicUrl) {
|
|
346
|
-
throw new Error(
|
|
347
|
-
'OAuth requires a public ingress URL in gateway-only mode. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// In gateway_only mode, always use gateway transport — never fall back to loopback
|
|
352
|
-
log.debug({ transport: 'gateway' }, 'OAuth2 flow starting (gateway_only mode)');
|
|
353
|
-
return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const transport = options?.callbackTransport ?? detectTransport();
|
|
357
|
-
log.debug({ transport }, 'OAuth2 flow starting');
|
|
358
|
-
|
|
359
|
-
if (transport === 'gateway') {
|
|
360
|
-
return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
213
|
+
if (!hasPublicUrl) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
'OAuth requires a public ingress URL. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
|
|
216
|
+
);
|
|
361
217
|
}
|
|
362
218
|
|
|
363
|
-
|
|
219
|
+
// Always use gateway transport — never fall back to loopback
|
|
220
|
+
log.debug({ transport: 'gateway' }, 'OAuth2 flow starting');
|
|
221
|
+
return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
364
222
|
}
|
|
365
223
|
|
|
366
224
|
/**
|
|
@@ -38,7 +38,7 @@ class MiniCDP {
|
|
|
38
38
|
const cb = this.callbacks.get(msg.id);
|
|
39
39
|
if (cb) {
|
|
40
40
|
this.callbacks.delete(msg.id);
|
|
41
|
-
msg.error
|
|
41
|
+
if (msg.error) { cb.reject(new Error(msg.error.message)); } else { cb.resolve(msg.result); }
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
};
|
|
@@ -130,7 +130,7 @@ export async function autoNavigate(domain: string, abortSignal?: { aborted: bool
|
|
|
130
130
|
await sleep(SCROLL_WAIT_MS);
|
|
131
131
|
|
|
132
132
|
// Discover internal links from the current page
|
|
133
|
-
|
|
133
|
+
const discoveredLinks = await discoverInternalLinks(cdp, domain);
|
|
134
134
|
log.info({ count: discoveredLinks.length }, 'Discovered internal links from root');
|
|
135
135
|
|
|
136
136
|
// Visit discovered pages
|
|
@@ -35,7 +35,7 @@ class MiniCDP {
|
|
|
35
35
|
const cb = this.callbacks.get(msg.id);
|
|
36
36
|
if (cb) {
|
|
37
37
|
this.callbacks.delete(msg.id);
|
|
38
|
-
msg.error
|
|
38
|
+
if (msg.error) { cb.reject(new Error(msg.error.message)); } else { cb.resolve(msg.result); }
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
};
|
|
@@ -156,7 +156,7 @@ export const claudeCodeTool: Tool = {
|
|
|
156
156
|
return { behavior: 'allow' as const };
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// For tools that need approval, bridge to
|
|
159
|
+
// For tools that need approval, bridge to Vellum's confirmation flow
|
|
160
160
|
if (!context.requestConfirmation) {
|
|
161
161
|
log.warn({ toolName }, 'Claude Code tool requires approval but no requestConfirmation callback available');
|
|
162
162
|
return { behavior: 'deny' as const, message: 'Tool approval not available in this context' };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { RiskLevel } from '../../permissions/types.js';
|
|
5
|
+
import type { ToolDefinition } from '../../providers/types.js';
|
|
6
|
+
import { registerTool } from '../registry.js';
|
|
7
|
+
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
8
|
+
|
|
9
|
+
function readPackageVersion(): string {
|
|
10
|
+
try {
|
|
11
|
+
const pkgPath = join(import.meta.dir, '../../../package.json');
|
|
12
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
|
13
|
+
return pkg.version ?? 'unknown';
|
|
14
|
+
} catch {
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class VersionTool implements Tool {
|
|
20
|
+
name = 'version';
|
|
21
|
+
description = 'Return the current version of the Vellum assistant daemon.';
|
|
22
|
+
category = 'system';
|
|
23
|
+
defaultRiskLevel = RiskLevel.Low;
|
|
24
|
+
|
|
25
|
+
getDefinition(): ToolDefinition {
|
|
26
|
+
return {
|
|
27
|
+
name: this.name,
|
|
28
|
+
description: this.description,
|
|
29
|
+
input_schema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {},
|
|
32
|
+
required: [],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async execute(_input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
38
|
+
const version = readPackageVersion();
|
|
39
|
+
return { content: `Vellum assistant version: ${version}`, isError: false };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
registerTool(new VersionTool());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
2
|
-
import { getWorkItem, listWorkItems, identifyEntityById
|
|
2
|
+
import { getWorkItem, listWorkItems, identifyEntityById } from '../../work-items/work-item-store.js';
|
|
3
3
|
import { runWorkItemInBackground } from '../../work-items/work-item-runner.js';
|
|
4
4
|
import { getTask } from '../../tasks/task-store.js';
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { getLogger } from '../../util/logger.js';
|
|
5
5
|
import { IntegrityError } from '../../util/errors.js';
|
|
@@ -75,11 +75,31 @@ function verifyWasmChecksum(filePath: string, label: string): void {
|
|
|
75
75
|
let parserInstance: Parser | null = null;
|
|
76
76
|
let initPromise: Promise<void> | null = null;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Locate a WASM file from a dependency package.
|
|
80
|
+
*
|
|
81
|
+
* In development / `bunx` the file lives under `node_modules/` relative
|
|
82
|
+
* to the source tree. In compiled Bun binaries `import.meta.dirname`
|
|
83
|
+
* points into the virtual `/$bunfs/` filesystem where binary assets
|
|
84
|
+
* don't exist — fall back to:
|
|
85
|
+
* 1. `../Resources/<file>` (macOS .app bundle layout)
|
|
86
|
+
* 2. Next to the compiled binary (process.execPath)
|
|
87
|
+
* This matches the pattern used by docker.ts for Dockerfile.sandbox.
|
|
88
|
+
*/
|
|
78
89
|
function findWasmPath(pkg: string, file: string): string {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
90
|
+
const dir = import.meta.dirname ?? __dirname;
|
|
91
|
+
const sourcePath = join(dir, '..', '..', '..', 'node_modules', pkg, file);
|
|
92
|
+
|
|
93
|
+
if (!existsSync(sourcePath) && dir.startsWith('/$bunfs/')) {
|
|
94
|
+
const execDir = dirname(process.execPath);
|
|
95
|
+
// macOS .app bundle: binary is in Contents/MacOS/, resources in Contents/Resources/
|
|
96
|
+
const resourcesPath = join(execDir, '..', 'Resources', file);
|
|
97
|
+
if (existsSync(resourcesPath)) return resourcesPath;
|
|
98
|
+
// Fallback: next to the binary itself (non-app-bundle deployments)
|
|
99
|
+
return join(execDir, file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sourcePath;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
async function ensureParser(): Promise<Parser> {
|
|
@@ -93,7 +113,9 @@ async function ensureParser(): Promise<Parser> {
|
|
|
93
113
|
verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
|
|
94
114
|
verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
|
|
95
115
|
|
|
96
|
-
await Parser.init(
|
|
116
|
+
await Parser.init({
|
|
117
|
+
locateFile: () => treeSitterWasm,
|
|
118
|
+
});
|
|
97
119
|
|
|
98
120
|
const Bash = await Language.load(bashWasmPath);
|
|
99
121
|
const parser = new Parser();
|
|
@@ -35,6 +35,7 @@ export async function loadEagerModules(): Promise<void> {
|
|
|
35
35
|
await import('./calls/call-start.js');
|
|
36
36
|
await import('./calls/call-status.js');
|
|
37
37
|
await import('./calls/call-end.js');
|
|
38
|
+
await import('./system/version.js');
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
// Tool names registered by the eager modules above. Listed explicitly so
|
|
@@ -57,6 +58,7 @@ export const eagerModuleToolNames: string[] = [
|
|
|
57
58
|
'call_start',
|
|
58
59
|
'call_status',
|
|
59
60
|
'call_end',
|
|
61
|
+
'version',
|
|
60
62
|
];
|
|
61
63
|
|
|
62
64
|
// ── Explicit tool instances ─────────────────────────────────────────
|
|
@@ -26,7 +26,7 @@ function proxyExecute(): Promise<ToolExecutionResult> {
|
|
|
26
26
|
export const uiShowTool: Tool = {
|
|
27
27
|
name: 'ui_show',
|
|
28
28
|
description:
|
|
29
|
-
'Show
|
|
29
|
+
'Show structured data or UI to the user. Use for displaying weather, flights, stock prices, quick tables, cards, lists, forms, or any temporary data visualization. Use display: "inline" (default) to embed in chat, or "panel" for a floating window. For long-form writing use the document skill instead; for interactive apps use the app-builder skill instead.\n\n' +
|
|
30
30
|
'Supported surface types:\n' +
|
|
31
31
|
'- card: Informational card with title, subtitle, body text, and optional metadata key-value pairs. ' +
|
|
32
32
|
'Cards support an optional template field for specialized native rendering. ' +
|
|
@@ -54,7 +54,14 @@ export const uiShowTool: Tool = {
|
|
|
54
54
|
'data shape: { prompt: string, acceptedTypes?: string[], maxFiles?: number }\n\n' +
|
|
55
55
|
'Action payload conventions:\n' +
|
|
56
56
|
'- Multi-select tables: use `window.vellum.sendAction(actionId, { selectedIds: [...] })` to send selected row IDs\n' +
|
|
57
|
-
'- Bulk actions: include `selectedRows` array with full row data for context'
|
|
57
|
+
'- Bulk actions: include `selectedRows` array with full row data for context\n\n' +
|
|
58
|
+
'Presenting choices: When the user needs to make a choice or provide structured input, prefer interactive surfaces over plain text. ' +
|
|
59
|
+
'Use list (2-8 options, single select), form (structured input with typed fields), confirmation (destructive/important actions), or table (data review with selectable rows).\n\n' +
|
|
60
|
+
'Tool chaining: After gathering data via tools (web search, browser, APIs), synthesize results into a visual output. ' +
|
|
61
|
+
'Exception: get_weather automatically renders its own surface — do NOT call ui_show or app_create after get_weather, just respond with a brief summary.\n\n' +
|
|
62
|
+
'Task progress for multi-step workflows: Create a card with template "task_progress" and templateData containing steps. ' +
|
|
63
|
+
'As each step completes, call ui_update to patch data.templateData (not top-level fields). ' +
|
|
64
|
+
'Set templateData.status to "completed" or "failed" when done.',
|
|
58
65
|
category: 'ui-surface',
|
|
59
66
|
defaultRiskLevel: RiskLevel.Low,
|
|
60
67
|
executionMode: 'proxy',
|