vellum 0.2.2 → 0.2.8
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 +68 -100
- package/package.json +3 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +20 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/twilio-config.ts +25 -9
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +47 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +34 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +95 -4
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -0
- package/src/daemon/ipc-contract.ts +39 -1
- package/src/daemon/ride-shotgun-handler.ts +2 -1
- package/src/daemon/session-agent-loop.ts +37 -2
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +140 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +131 -1
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- package/src/workspace/provider-commit-message-generator.ts +57 -14
- package/src/workspace/turn-commit.ts +6 -2
|
@@ -212,6 +212,8 @@ export interface RideShotgunStart {
|
|
|
212
212
|
intervalSeconds: number;
|
|
213
213
|
mode?: 'observe' | 'learn';
|
|
214
214
|
targetDomain?: string;
|
|
215
|
+
/** Domain to auto-navigate (may differ from targetDomain, e.g. open.spotify.com vs spotify.com). */
|
|
216
|
+
navigateDomain?: string;
|
|
215
217
|
autoNavigate?: boolean;
|
|
216
218
|
}
|
|
217
219
|
|
|
@@ -470,6 +472,18 @@ export interface SlackWebhookConfigRequest {
|
|
|
470
472
|
webhookUrl?: string;
|
|
471
473
|
}
|
|
472
474
|
|
|
475
|
+
export interface TwilioWebhookConfigRequest {
|
|
476
|
+
type: 'twilio_webhook_config';
|
|
477
|
+
action: 'get' | 'set';
|
|
478
|
+
webhookBaseUrl?: string;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export interface IngressConfigRequest {
|
|
482
|
+
type: 'ingress_config';
|
|
483
|
+
action: 'get' | 'set';
|
|
484
|
+
publicBaseUrl?: string;
|
|
485
|
+
}
|
|
486
|
+
|
|
473
487
|
export interface VercelApiConfigRequest {
|
|
474
488
|
type: 'vercel_api_config';
|
|
475
489
|
action: 'get' | 'set' | 'delete';
|
|
@@ -933,6 +947,8 @@ export type ClientMessage =
|
|
|
933
947
|
| ShareAppCloudRequest
|
|
934
948
|
| ShareToSlackRequest
|
|
935
949
|
| SlackWebhookConfigRequest
|
|
950
|
+
| TwilioWebhookConfigRequest
|
|
951
|
+
| IngressConfigRequest
|
|
936
952
|
| VercelApiConfigRequest
|
|
937
953
|
| TwitterIntegrationConfigRequest
|
|
938
954
|
| TwitterAuthStartRequest
|
|
@@ -1041,6 +1057,12 @@ export interface ToolUseStart {
|
|
|
1041
1057
|
export interface ToolOutputChunk {
|
|
1042
1058
|
type: 'tool_output_chunk';
|
|
1043
1059
|
chunk: string;
|
|
1060
|
+
sessionId?: string;
|
|
1061
|
+
subType?: 'tool_start' | 'tool_complete' | 'status';
|
|
1062
|
+
subToolName?: string;
|
|
1063
|
+
subToolInput?: string;
|
|
1064
|
+
subToolIsError?: boolean;
|
|
1065
|
+
subToolId?: string;
|
|
1044
1066
|
}
|
|
1045
1067
|
|
|
1046
1068
|
export interface ToolInputDelta {
|
|
@@ -1675,6 +1697,20 @@ export interface SlackWebhookConfigResponse {
|
|
|
1675
1697
|
error?: string;
|
|
1676
1698
|
}
|
|
1677
1699
|
|
|
1700
|
+
export interface TwilioWebhookConfigResponse {
|
|
1701
|
+
type: 'twilio_webhook_config_response';
|
|
1702
|
+
webhookBaseUrl: string;
|
|
1703
|
+
success: boolean;
|
|
1704
|
+
error?: string;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
export interface IngressConfigResponse {
|
|
1708
|
+
type: 'ingress_config_response';
|
|
1709
|
+
publicBaseUrl: string;
|
|
1710
|
+
success: boolean;
|
|
1711
|
+
error?: string;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1678
1714
|
export interface OpenUrl {
|
|
1679
1715
|
type: 'open_url';
|
|
1680
1716
|
url: string;
|
|
@@ -1993,7 +2029,7 @@ export interface WorkItemDeleteResponse {
|
|
|
1993
2029
|
success: boolean;
|
|
1994
2030
|
}
|
|
1995
2031
|
|
|
1996
|
-
export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task';
|
|
2032
|
+
export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task' | 'permission_required';
|
|
1997
2033
|
|
|
1998
2034
|
export interface WorkItemRunTaskResponse {
|
|
1999
2035
|
type: 'work_item_run_task_response';
|
|
@@ -2158,6 +2194,8 @@ export type ServerMessage =
|
|
|
2158
2194
|
| GalleryInstallResponse
|
|
2159
2195
|
| ShareToSlackResponse
|
|
2160
2196
|
| SlackWebhookConfigResponse
|
|
2197
|
+
| TwilioWebhookConfigResponse
|
|
2198
|
+
| IngressConfigResponse
|
|
2161
2199
|
| VercelApiConfigResponse
|
|
2162
2200
|
| TwitterIntegrationConfigResponse
|
|
2163
2201
|
| TwitterAuthResult
|
|
@@ -161,6 +161,7 @@ export async function handleRideShotgunStart(
|
|
|
161
161
|
}
|
|
162
162
|
});
|
|
163
163
|
} else if (msg.autoNavigate && targetDomain) {
|
|
164
|
+
const navDomain = msg.navigateDomain ?? targetDomain;
|
|
164
165
|
const abortSignal = { aborted: false };
|
|
165
166
|
const checkInterval = setInterval(() => {
|
|
166
167
|
if (session.status !== 'active') {
|
|
@@ -168,7 +169,7 @@ export async function handleRideShotgunStart(
|
|
|
168
169
|
clearInterval(checkInterval);
|
|
169
170
|
}
|
|
170
171
|
}, 1000);
|
|
171
|
-
autoNavigate(
|
|
172
|
+
autoNavigate(navDomain, abortSignal).then(visited => {
|
|
172
173
|
clearInterval(checkInterval);
|
|
173
174
|
log.info({ watchId, visitedPages: visited.length }, 'Generic auto-navigation finished');
|
|
174
175
|
if (session.status === 'active') {
|
|
@@ -350,9 +350,44 @@ export async function runAgentLoopImpl(
|
|
|
350
350
|
currentTurnToolNames.push(event.name);
|
|
351
351
|
onEvent({ type: 'tool_use_start', toolName: event.name, input: event.input, sessionId: ctx.conversationId });
|
|
352
352
|
break;
|
|
353
|
-
case 'tool_output_chunk':
|
|
354
|
-
|
|
353
|
+
case 'tool_output_chunk': {
|
|
354
|
+
// Try to parse structured progress fields from the chunk.
|
|
355
|
+
// Cheap pre-check: only attempt JSON.parse when the chunk looks like an object.
|
|
356
|
+
let structured: { subType?: 'tool_start' | 'tool_complete' | 'status'; subToolName?: string; subToolInput?: string; subToolIsError?: boolean; subToolId?: string } | undefined;
|
|
357
|
+
const trimmed = event.chunk.trimStart();
|
|
358
|
+
if (trimmed.length > 0 && trimmed.length < 4096 && trimmed[0] === '{') {
|
|
359
|
+
try {
|
|
360
|
+
const parsed = JSON.parse(event.chunk);
|
|
361
|
+
const VALID_SUB_TYPES = new Set(['tool_start', 'tool_complete', 'status']);
|
|
362
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.subType === 'string' && VALID_SUB_TYPES.has(parsed.subType)) {
|
|
363
|
+
structured = {
|
|
364
|
+
subType: parsed.subType as 'tool_start' | 'tool_complete' | 'status',
|
|
365
|
+
subToolName: typeof parsed.subToolName === 'string' ? parsed.subToolName : undefined,
|
|
366
|
+
subToolInput: typeof parsed.subToolInput === 'string' ? parsed.subToolInput : undefined,
|
|
367
|
+
subToolIsError: typeof parsed.subToolIsError === 'boolean' ? parsed.subToolIsError : undefined,
|
|
368
|
+
subToolId: typeof parsed.subToolId === 'string' ? parsed.subToolId : undefined,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Not valid JSON — pass through as plain chunk
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (structured) {
|
|
376
|
+
onEvent({
|
|
377
|
+
type: 'tool_output_chunk',
|
|
378
|
+
chunk: event.chunk,
|
|
379
|
+
sessionId: ctx.conversationId,
|
|
380
|
+
subType: structured.subType,
|
|
381
|
+
subToolName: structured.subToolName,
|
|
382
|
+
subToolInput: structured.subToolInput,
|
|
383
|
+
subToolIsError: structured.subToolIsError,
|
|
384
|
+
subToolId: structured.subToolId,
|
|
385
|
+
});
|
|
386
|
+
} else {
|
|
387
|
+
onEvent({ type: 'tool_output_chunk', chunk: event.chunk, sessionId: ctx.conversationId });
|
|
388
|
+
}
|
|
355
389
|
break;
|
|
390
|
+
}
|
|
356
391
|
case 'input_json_delta':
|
|
357
392
|
onEvent({ type: 'tool_input_delta', toolName: event.toolName, content: event.accumulatedJson, sessionId: ctx.conversationId });
|
|
358
393
|
break;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conflict-gate logic extracted from Session.
|
|
3
3
|
*
|
|
4
|
-
* Decides whether to ask the user about a pending memory conflict (relevant gate)
|
|
5
|
-
*
|
|
4
|
+
* Decides whether to ask the user about a pending memory conflict (relevant gate)
|
|
5
|
+
* or skip entirely.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
} from '../memory/conflict-store.js';
|
|
13
13
|
import type { PendingConflictDetail } from '../memory/conflict-store.js';
|
|
14
14
|
import { resolveConflictClarification } from '../memory/clarification-resolver.js';
|
|
15
|
+
import {
|
|
16
|
+
computeConflictRelevance,
|
|
17
|
+
looksLikeClarificationReply,
|
|
18
|
+
shouldAttemptConflictResolution,
|
|
19
|
+
} from '../memory/conflict-intent.js';
|
|
15
20
|
|
|
16
21
|
export interface ConflictGateDecision {
|
|
17
22
|
question: string;
|
|
@@ -39,14 +44,14 @@ export class ConflictGate {
|
|
|
39
44
|
const threshold = conflictConfig.relevanceThreshold;
|
|
40
45
|
const cooldownTurns = Math.max(1, conflictConfig.reaskCooldownTurns);
|
|
41
46
|
const pendingBeforeResolve = listPendingConflictDetails(scopeId, 50);
|
|
47
|
+
const clarificationReply = looksLikeClarificationReply(userMessage);
|
|
42
48
|
const candidatesBeforeResolve = pendingBeforeResolve.filter((conflict) => {
|
|
43
49
|
const relevance = computeConflictRelevance(userMessage, conflict);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
&& looksLikeClarificationReply(userMessage);
|
|
50
|
+
return shouldAttemptConflictResolution({
|
|
51
|
+
clarificationReply,
|
|
52
|
+
relevance,
|
|
53
|
+
wasRecentlyAsked: this.wasRecentlyAsked(conflict.id, cooldownTurns),
|
|
54
|
+
});
|
|
50
55
|
});
|
|
51
56
|
await this.resolvePendingConflicts(
|
|
52
57
|
userMessage,
|
|
@@ -61,18 +66,16 @@ export class ConflictGate {
|
|
|
61
66
|
conflict,
|
|
62
67
|
relevance: computeConflictRelevance(userMessage, conflict),
|
|
63
68
|
}));
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const askable = ordered.find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
|
|
69
|
+
const askable = scored
|
|
70
|
+
.filter((entry) => entry.relevance >= threshold)
|
|
71
|
+
.find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
|
|
69
72
|
if (!askable) return null;
|
|
70
73
|
|
|
71
74
|
this.lastAskedTurn.set(askable.conflict.id, this.turnCounter);
|
|
72
75
|
markConflictAsked(askable.conflict.id);
|
|
73
76
|
return {
|
|
74
77
|
question: askable.conflict.clarificationQuestion ?? buildFallbackConflictQuestion(askable.conflict),
|
|
75
|
-
relevant:
|
|
78
|
+
relevant: true,
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
|
|
@@ -122,98 +125,4 @@ export function buildFallbackConflictQuestion(conflict: PendingConflictDetail):
|
|
|
122
125
|
'Which one should I keep?',
|
|
123
126
|
].join('\n');
|
|
124
127
|
}
|
|
125
|
-
|
|
126
|
-
export function computeConflictRelevance(
|
|
127
|
-
userMessage: string,
|
|
128
|
-
conflict: Pick<PendingConflictDetail, 'existingStatement' | 'candidateStatement'>,
|
|
129
|
-
): number {
|
|
130
|
-
const queryTokens = tokenizeForConflictRelevance(userMessage);
|
|
131
|
-
if (queryTokens.size === 0) return 0;
|
|
132
|
-
const existingTokens = tokenizeForConflictRelevance(conflict.existingStatement);
|
|
133
|
-
const candidateTokens = tokenizeForConflictRelevance(conflict.candidateStatement);
|
|
134
|
-
return Math.max(
|
|
135
|
-
overlapRatio(queryTokens, existingTokens),
|
|
136
|
-
overlapRatio(queryTokens, candidateTokens),
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function tokenizeForConflictRelevance(input: string): Set<string> {
|
|
141
|
-
const tokens = input
|
|
142
|
-
.toLowerCase()
|
|
143
|
-
.split(/[^a-z0-9]+/g)
|
|
144
|
-
.map((token) => token.trim())
|
|
145
|
-
.filter((token) => token.length >= 4);
|
|
146
|
-
return new Set(tokens);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
150
|
-
if (left.size === 0 || right.size === 0) return 0;
|
|
151
|
-
let overlap = 0;
|
|
152
|
-
for (const token of left) {
|
|
153
|
-
if (right.has(token)) overlap += 1;
|
|
154
|
-
}
|
|
155
|
-
return overlap / Math.max(left.size, right.size);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Action verbs that signal the user is making a deliberate choice.
|
|
159
|
-
const ACTION_CUES = new Set([
|
|
160
|
-
'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',
|
|
161
|
-
]);
|
|
162
|
-
|
|
163
|
-
// Directional/merge cue words mirrored from clarification-resolver.ts heuristics.
|
|
164
|
-
const DIRECTIONAL_CUES = new Set([
|
|
165
|
-
'existing', 'old', 'previous', 'first', 'earlier', 'original',
|
|
166
|
-
'candidate', 'new', 'latest', 'second', 'updated', 'instead', 'replace',
|
|
167
|
-
'both', 'merge', 'combine', 'together', 'either', 'mix',
|
|
168
|
-
'option', 'former', 'latter',
|
|
169
|
-
]);
|
|
170
|
-
|
|
171
|
-
const MAX_REPLY_WORD_COUNT = 12;
|
|
172
|
-
|
|
173
|
-
// Direction-only matches (no action verb) must be very short to avoid
|
|
174
|
-
// false positives from unrelated statements that happen to contain
|
|
175
|
-
// common words like "new", "old", "option", etc.
|
|
176
|
-
const MAX_DIRECTION_ONLY_WORD_COUNT = 4;
|
|
177
|
-
|
|
178
|
-
// Messages starting with a question word are unlikely to be clarification
|
|
179
|
-
// replies even when they lack a trailing question mark.
|
|
180
|
-
const QUESTION_WORD_PREFIXES = new Set([
|
|
181
|
-
'what', 'how', 'why', 'where', 'when', 'which', 'who', 'whom', 'whose',
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Determines whether a user message looks like a deliberate reply to a
|
|
186
|
-
* recently asked conflict clarification (e.g. "keep the new one", "both",
|
|
187
|
-
* "option B", "new one"). Requires at least one action or directional cue,
|
|
188
|
-
* and the message must be short. Questions and longer statements are excluded
|
|
189
|
-
* to prevent accidental resolution from unrelated messages.
|
|
190
|
-
*
|
|
191
|
-
* When only directional cues are present (no action verb), the message must
|
|
192
|
-
* be very short (<= 4 words) to avoid false positives from unrelated messages
|
|
193
|
-
* like "What's new in Bun" or "try the old approach".
|
|
194
|
-
*/
|
|
195
|
-
export function looksLikeClarificationReply(userMessage: string): boolean {
|
|
196
|
-
const trimmed = userMessage.trim();
|
|
197
|
-
if (trimmed.endsWith('?')) return false;
|
|
198
|
-
|
|
199
|
-
const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
200
|
-
if (words.length === 0 || words.length > MAX_REPLY_WORD_COUNT) return false;
|
|
201
|
-
|
|
202
|
-
const normalized = words.map((w) => w.replace(/[^a-z]/g, ''));
|
|
203
|
-
|
|
204
|
-
// Reject messages that start with a question word (even without '?').
|
|
205
|
-
// Match exact question words or contractions (e.g. "what's", "where's"),
|
|
206
|
-
// but not words that merely share a prefix (e.g. "whichever", "however").
|
|
207
|
-
const firstWord = words[0];
|
|
208
|
-
const firstNorm = normalized[0];
|
|
209
|
-
for (const qw of QUESTION_WORD_PREFIXES) {
|
|
210
|
-
if (firstNorm === qw || (firstWord.startsWith(qw) && "'\u2018\u2019".includes(firstWord[qw.length]))) return false;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const hasAction = normalized.some((w) => ACTION_CUES.has(w));
|
|
214
|
-
const hasDirection = normalized.some((w) => DIRECTIONAL_CUES.has(w));
|
|
215
|
-
|
|
216
|
-
if (hasAction) return true;
|
|
217
|
-
if (hasDirection) return words.length <= MAX_DIRECTION_ONLY_WORD_COUNT;
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
128
|
+
export { computeConflictRelevance, looksLikeClarificationReply };
|
|
@@ -14,6 +14,9 @@ import type { PermissionPrompter } from '../permissions/prompter.js';
|
|
|
14
14
|
import type { SecretPrompter } from '../permissions/secret-prompter.js';
|
|
15
15
|
import { addRule, findHighestPriorityRule } from '../permissions/trust-store.js';
|
|
16
16
|
import { generateAllowlistOptions, generateScopeOptions, normalizeWebFetchUrl } from '../permissions/checker.js';
|
|
17
|
+
import { getLogger } from '../util/logger.js';
|
|
18
|
+
|
|
19
|
+
const log = getLogger('session-tool-setup');
|
|
17
20
|
import { getAllToolDefinitions } from '../tools/registry.js';
|
|
18
21
|
import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
|
|
19
22
|
import { coreAppProxyTools } from '../tools/apps/definitions.js';
|
|
@@ -248,10 +251,12 @@ export function createToolExecutor(
|
|
|
248
251
|
req.principal,
|
|
249
252
|
);
|
|
250
253
|
if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
|
|
254
|
+
log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
|
|
251
255
|
addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
|
|
252
256
|
response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
|
|
253
257
|
}
|
|
254
258
|
if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
|
|
259
|
+
log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule');
|
|
255
260
|
addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'deny');
|
|
256
261
|
}
|
|
257
262
|
return {
|
|
@@ -440,10 +445,12 @@ export function createProxyApprovalCallback(
|
|
|
440
445
|
|
|
441
446
|
// Persist trust rule if the user chose "always allow" or "always deny"
|
|
442
447
|
if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
|
|
448
|
+
log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule (proxy)');
|
|
443
449
|
addRule(toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
|
|
444
450
|
response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
|
|
445
451
|
}
|
|
446
452
|
if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
|
|
453
|
+
log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule (proxy)');
|
|
447
454
|
addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
|
|
448
455
|
}
|
|
449
456
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized URL builders for all public-facing ingress endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the canonical public base URL via a fallback chain:
|
|
5
|
+
* ingress.publicBaseUrl → calls.webhookBaseUrl → env TWILIO_WEBHOOK_BASE_URL
|
|
6
|
+
*
|
|
7
|
+
* Supersedes the per-domain URL helpers in calls/twilio-webhook-urls.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
|
|
12
|
+
const log = getLogger('public-ingress-urls');
|
|
13
|
+
|
|
14
|
+
export interface IngressConfig {
|
|
15
|
+
ingress?: { publicBaseUrl?: string };
|
|
16
|
+
calls?: { webhookBaseUrl?: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Trim whitespace and strip trailing slashes from a URL string.
|
|
21
|
+
*/
|
|
22
|
+
function normalizeUrl(url: string): string {
|
|
23
|
+
return url.trim().replace(/\/+$/, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the canonical public base URL with a three-level fallback chain:
|
|
28
|
+
* 1. ingress.publicBaseUrl (preferred)
|
|
29
|
+
* 2. calls.webhookBaseUrl (backward compat)
|
|
30
|
+
* 3. TWILIO_WEBHOOK_BASE_URL env var (legacy, deprecated)
|
|
31
|
+
*
|
|
32
|
+
* Throws if no source provides a non-empty value.
|
|
33
|
+
*/
|
|
34
|
+
export function getPublicBaseUrl(config: IngressConfig): string {
|
|
35
|
+
const ingressValue = config.ingress?.publicBaseUrl;
|
|
36
|
+
if (ingressValue) {
|
|
37
|
+
const normalized = normalizeUrl(ingressValue);
|
|
38
|
+
if (normalized) return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const callsValue = config.calls?.webhookBaseUrl;
|
|
42
|
+
if (callsValue) {
|
|
43
|
+
const normalized = normalizeUrl(callsValue);
|
|
44
|
+
if (normalized) {
|
|
45
|
+
log.warn(
|
|
46
|
+
'Using calls.webhookBaseUrl as public base URL — set ingress.publicBaseUrl instead.',
|
|
47
|
+
);
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
53
|
+
if (envValue) {
|
|
54
|
+
log.warn(
|
|
55
|
+
'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set ingress.publicBaseUrl in config instead.',
|
|
56
|
+
);
|
|
57
|
+
const normalized = normalizeUrl(envValue);
|
|
58
|
+
if (normalized) return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
'No public base URL configured. Set ingress.publicBaseUrl in config, calls.webhookBaseUrl, or TWILIO_WEBHOOK_BASE_URL env var.',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the Twilio voice webhook URL for a given call session.
|
|
68
|
+
*/
|
|
69
|
+
export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId: string): string {
|
|
70
|
+
const base = getPublicBaseUrl(config);
|
|
71
|
+
return `${base}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the Twilio status callback URL.
|
|
76
|
+
*/
|
|
77
|
+
export function getTwilioStatusCallbackUrl(config: IngressConfig): string {
|
|
78
|
+
const base = getPublicBaseUrl(config);
|
|
79
|
+
return `${base}/webhooks/twilio/status`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the Twilio connect-action callback URL.
|
|
84
|
+
*/
|
|
85
|
+
export function getTwilioConnectActionUrl(config: IngressConfig): string {
|
|
86
|
+
const base = getPublicBaseUrl(config);
|
|
87
|
+
return `${base}/webhooks/twilio/connect-action`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the Twilio ConversationRelay WebSocket URL.
|
|
92
|
+
* Converts http:// → ws:// and https:// → wss://.
|
|
93
|
+
*/
|
|
94
|
+
export function getTwilioRelayUrl(config: IngressConfig): string {
|
|
95
|
+
const base = getPublicBaseUrl(config);
|
|
96
|
+
const wsBase = base.replace(/^http(s?)/, 'ws$1');
|
|
97
|
+
return `${wsBase}/webhooks/twilio/relay`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the OAuth callback URL.
|
|
102
|
+
*/
|
|
103
|
+
export function getOAuthCallbackUrl(config: IngressConfig): string {
|
|
104
|
+
const base = getPublicBaseUrl(config);
|
|
105
|
+
return `${base}/webhooks/oauth/callback`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared conflict intent helpers used by both interactive conflict gating and
|
|
3
|
+
* background conflict resolution jobs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ConflictStatementPair {
|
|
7
|
+
existingStatement: string;
|
|
8
|
+
candidateStatement: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function computeConflictRelevance(
|
|
12
|
+
userMessage: string,
|
|
13
|
+
conflict: ConflictStatementPair,
|
|
14
|
+
): number {
|
|
15
|
+
const queryTokens = tokenizeForConflictRelevance(userMessage);
|
|
16
|
+
if (queryTokens.size === 0) return 0;
|
|
17
|
+
const existingTokens = tokenizeForConflictRelevance(conflict.existingStatement);
|
|
18
|
+
const candidateTokens = tokenizeForConflictRelevance(conflict.candidateStatement);
|
|
19
|
+
return Math.max(
|
|
20
|
+
overlapRatio(queryTokens, existingTokens),
|
|
21
|
+
overlapRatio(queryTokens, candidateTokens),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tokenizeForConflictRelevance(input: string): Set<string> {
|
|
26
|
+
const tokens = input
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.split(/[^a-z0-9]+/g)
|
|
29
|
+
.map((token) => token.trim())
|
|
30
|
+
.filter((token) => token.length >= 4);
|
|
31
|
+
return new Set(tokens);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
35
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
36
|
+
let overlap = 0;
|
|
37
|
+
for (const token of left) {
|
|
38
|
+
if (right.has(token)) overlap += 1;
|
|
39
|
+
}
|
|
40
|
+
return overlap / Math.max(left.size, right.size);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Action verbs that signal the user is making a deliberate choice.
|
|
44
|
+
const ACTION_CUES = new Set([
|
|
45
|
+
'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Directional/merge cue words mirrored from clarification-resolver.ts heuristics.
|
|
49
|
+
const DIRECTIONAL_CUES = new Set([
|
|
50
|
+
'existing', 'old', 'previous', 'first', 'earlier', 'original',
|
|
51
|
+
'candidate', 'new', 'latest', 'second', 'updated', 'instead', 'replace',
|
|
52
|
+
'both', 'merge', 'combine', 'together', 'either', 'mix',
|
|
53
|
+
'option', 'former', 'latter',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const MAX_REPLY_WORD_COUNT = 12;
|
|
57
|
+
|
|
58
|
+
// Direction-only matches (no action verb) must be very short to avoid
|
|
59
|
+
// false positives from unrelated statements that happen to contain
|
|
60
|
+
// common words like "new", "old", "option", etc.
|
|
61
|
+
const MAX_DIRECTION_ONLY_WORD_COUNT = 4;
|
|
62
|
+
|
|
63
|
+
// Messages starting with a question word are unlikely to be clarification
|
|
64
|
+
// replies even when they lack a trailing question mark.
|
|
65
|
+
const QUESTION_WORD_PREFIXES = new Set([
|
|
66
|
+
'what', 'how', 'why', 'where', 'when', 'which', 'who', 'whom', 'whose',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Determines whether a user message looks like a deliberate clarification
|
|
71
|
+
* reply (e.g. "keep the new one", "both", "option B").
|
|
72
|
+
*/
|
|
73
|
+
export function looksLikeClarificationReply(userMessage: string): boolean {
|
|
74
|
+
const trimmed = userMessage.trim();
|
|
75
|
+
if (trimmed.endsWith('?')) return false;
|
|
76
|
+
|
|
77
|
+
const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
78
|
+
if (words.length === 0 || words.length > MAX_REPLY_WORD_COUNT) return false;
|
|
79
|
+
|
|
80
|
+
const normalized = words.map((w) => w.replace(/[^a-z]/g, ''));
|
|
81
|
+
|
|
82
|
+
// Reject messages that start with a question word (even without '?').
|
|
83
|
+
// Match exact question words or contractions (e.g. "what's", "where's"),
|
|
84
|
+
// but not words that merely share a prefix (e.g. "whichever", "however").
|
|
85
|
+
const firstWord = words[0];
|
|
86
|
+
const firstNorm = normalized[0];
|
|
87
|
+
for (const qw of QUESTION_WORD_PREFIXES) {
|
|
88
|
+
if (firstNorm === qw || (firstWord.startsWith(qw) && "'\u2018\u2019".includes(firstWord[qw.length]))) return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hasAction = normalized.some((w) => ACTION_CUES.has(w));
|
|
92
|
+
const hasDirection = normalized.some((w) => DIRECTIONAL_CUES.has(w));
|
|
93
|
+
|
|
94
|
+
if (hasAction) return true;
|
|
95
|
+
if (hasDirection) return words.length <= MAX_DIRECTION_ONLY_WORD_COUNT;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Conflict resolution should require explicit clarification intent and either:
|
|
101
|
+
* - non-zero topical overlap with the conflict statements, or
|
|
102
|
+
* - a very recent explicit ask from the assistant.
|
|
103
|
+
*/
|
|
104
|
+
export function shouldAttemptConflictResolution(
|
|
105
|
+
input: {
|
|
106
|
+
clarificationReply: boolean;
|
|
107
|
+
relevance: number;
|
|
108
|
+
wasRecentlyAsked: boolean;
|
|
109
|
+
},
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!input.clarificationReply) return false;
|
|
112
|
+
if (input.relevance > 0) return true;
|
|
113
|
+
return input.wasRecentlyAsked;
|
|
114
|
+
}
|