@vellumai/assistant 0.3.2 → 0.3.3
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/README.md +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -53,27 +53,34 @@ const log = getLogger('runtime-http');
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Header name used by the gateway to prove a request originated from it.
|
|
56
|
-
* The gateway
|
|
57
|
-
* it using constant-time comparison.
|
|
58
|
-
* that lack a valid
|
|
56
|
+
* The gateway sends a dedicated gateway-origin secret (or the bearer token
|
|
57
|
+
* as fallback). The runtime validates it using constant-time comparison.
|
|
58
|
+
* Requests to `/channels/inbound` that lack a valid proof are rejected with 403.
|
|
59
59
|
*/
|
|
60
60
|
export const GATEWAY_ORIGIN_HEADER = 'X-Gateway-Origin';
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Validate that the request carries a valid gateway-origin proof.
|
|
64
|
-
*
|
|
65
|
-
* using constant-time comparison to prevent timing attacks.
|
|
64
|
+
* Uses constant-time comparison to prevent timing attacks.
|
|
66
65
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* The `gatewayOriginSecret` parameter is the dedicated secret configured
|
|
67
|
+
* via `RUNTIME_GATEWAY_ORIGIN_SECRET`. When set, only this value is
|
|
68
|
+
* accepted. When not set, the function falls back to `bearerToken` for
|
|
69
|
+
* backward compatibility. When neither is configured (local dev), validation
|
|
70
|
+
* is skipped entirely.
|
|
70
71
|
*/
|
|
71
|
-
export function verifyGatewayOrigin(
|
|
72
|
-
|
|
72
|
+
export function verifyGatewayOrigin(
|
|
73
|
+
req: Request,
|
|
74
|
+
bearerToken?: string,
|
|
75
|
+
gatewayOriginSecret?: string,
|
|
76
|
+
): boolean {
|
|
77
|
+
// Determine the expected secret: prefer dedicated secret, fall back to bearer token
|
|
78
|
+
const expectedSecret = gatewayOriginSecret ?? bearerToken;
|
|
79
|
+
if (!expectedSecret) return true; // No shared secret configured — skip validation
|
|
73
80
|
const provided = req.headers.get(GATEWAY_ORIGIN_HEADER);
|
|
74
81
|
if (!provided) return false;
|
|
75
82
|
const a = Buffer.from(provided);
|
|
76
|
-
const b = Buffer.from(
|
|
83
|
+
const b = Buffer.from(expectedSecret);
|
|
77
84
|
if (a.length !== b.length) return false;
|
|
78
85
|
return timingSafeEqual(a, b);
|
|
79
86
|
}
|
|
@@ -84,6 +91,9 @@ export function verifyGatewayOrigin(req: Request, bearerToken?: string): boolean
|
|
|
84
91
|
|
|
85
92
|
export type ActorRole = 'guardian' | 'non-guardian' | 'unverified_channel';
|
|
86
93
|
|
|
94
|
+
/** Sub-reason for `unverified_channel` denials. */
|
|
95
|
+
export type DenialReason = 'no_binding' | 'no_identity';
|
|
96
|
+
|
|
87
97
|
export interface GuardianContext {
|
|
88
98
|
actorRole: ActorRole;
|
|
89
99
|
/** The guardian's delivery chat ID (from the guardian binding). */
|
|
@@ -96,6 +106,8 @@ export interface GuardianContext {
|
|
|
96
106
|
requesterExternalUserId?: string;
|
|
97
107
|
/** The requester's chat ID. */
|
|
98
108
|
requesterChatId?: string;
|
|
109
|
+
/** Sub-reason when actorRole is 'unverified_channel'. */
|
|
110
|
+
denialReason?: DenialReason;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
/** Guardian approval request expiry (30 minutes). */
|
|
@@ -142,7 +154,7 @@ function parseCallbackData(data: string): ApprovalDecisionResult | null {
|
|
|
142
154
|
return { action: action as ApprovalAction, source: 'telegram_button', runId };
|
|
143
155
|
}
|
|
144
156
|
|
|
145
|
-
export async function handleDeleteConversation(req: Request): Promise<Response> {
|
|
157
|
+
export async function handleDeleteConversation(req: Request, assistantId: string = 'self'): Promise<Response> {
|
|
146
158
|
const body = await req.json() as {
|
|
147
159
|
sourceChannel?: string;
|
|
148
160
|
externalChatId?: string;
|
|
@@ -157,8 +169,12 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
|
|
|
157
169
|
return Response.json({ error: 'externalChatId is required' }, { status: 400 });
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
|
|
161
|
-
|
|
172
|
+
// Delete both legacy and scoped conversation key aliases to handle
|
|
173
|
+
// migration scenarios where either or both keys may exist.
|
|
174
|
+
const legacyKey = `${sourceChannel}:${externalChatId}`;
|
|
175
|
+
const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
|
|
176
|
+
deleteConversationKey(legacyKey);
|
|
177
|
+
deleteConversationKey(scopedKey);
|
|
162
178
|
externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
|
|
163
179
|
|
|
164
180
|
return Response.json({ ok: true });
|
|
@@ -169,11 +185,13 @@ export async function handleChannelInbound(
|
|
|
169
185
|
processMessage?: MessageProcessor,
|
|
170
186
|
bearerToken?: string,
|
|
171
187
|
runOrchestrator?: RunOrchestrator,
|
|
188
|
+
assistantId: string = 'self',
|
|
189
|
+
gatewayOriginSecret?: string,
|
|
172
190
|
): Promise<Response> {
|
|
173
191
|
// Reject requests that lack valid gateway-origin proof. This ensures
|
|
174
192
|
// channel inbound messages can only arrive via the gateway (which
|
|
175
193
|
// performs webhook-level verification) and not via direct HTTP calls.
|
|
176
|
-
if (!verifyGatewayOrigin(req, bearerToken)) {
|
|
194
|
+
if (!verifyGatewayOrigin(req, bearerToken, gatewayOriginSecret)) {
|
|
177
195
|
log.warn('Rejected channel inbound request: missing or invalid gateway-origin proof');
|
|
178
196
|
return Response.json(
|
|
179
197
|
{ error: 'Forbidden: missing gateway-origin proof', code: 'GATEWAY_ORIGIN_REQUIRED' },
|
|
@@ -258,7 +276,7 @@ export async function handleChannelInbound(
|
|
|
258
276
|
sourceChannel,
|
|
259
277
|
externalChatId,
|
|
260
278
|
externalMessageId,
|
|
261
|
-
{ sourceMessageId },
|
|
279
|
+
{ sourceMessageId, assistantId },
|
|
262
280
|
);
|
|
263
281
|
|
|
264
282
|
if (editResult.duplicate) {
|
|
@@ -285,7 +303,7 @@ export async function handleChannelInbound(
|
|
|
285
303
|
if (original) break;
|
|
286
304
|
if (attempt < EDIT_LOOKUP_RETRIES) {
|
|
287
305
|
log.info(
|
|
288
|
-
{ assistantId
|
|
306
|
+
{ assistantId, sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
|
|
289
307
|
'Original message not linked yet, retrying edit lookup',
|
|
290
308
|
);
|
|
291
309
|
await new Promise((resolve) => setTimeout(resolve, EDIT_LOOKUP_DELAY_MS));
|
|
@@ -295,12 +313,12 @@ export async function handleChannelInbound(
|
|
|
295
313
|
if (original) {
|
|
296
314
|
conversationStore.updateMessageContent(original.messageId, content ?? '');
|
|
297
315
|
log.info(
|
|
298
|
-
{ assistantId
|
|
316
|
+
{ assistantId, sourceMessageId, messageId: original.messageId },
|
|
299
317
|
'Updated message content from edited_message',
|
|
300
318
|
);
|
|
301
319
|
} else {
|
|
302
320
|
log.warn(
|
|
303
|
-
{ assistantId
|
|
321
|
+
{ assistantId, sourceChannel, externalChatId, sourceMessageId },
|
|
304
322
|
'Could not find original message for edit after retries, ignoring',
|
|
305
323
|
);
|
|
306
324
|
}
|
|
@@ -317,7 +335,7 @@ export async function handleChannelInbound(
|
|
|
317
335
|
sourceChannel,
|
|
318
336
|
externalChatId,
|
|
319
337
|
externalMessageId,
|
|
320
|
-
{ sourceMessageId },
|
|
338
|
+
{ sourceMessageId, assistantId },
|
|
321
339
|
);
|
|
322
340
|
|
|
323
341
|
// Upsert external conversation binding with sender metadata
|
|
@@ -351,7 +369,7 @@ export async function handleChannelInbound(
|
|
|
351
369
|
const token = trimmedContent.slice('/guardian_verify '.length).trim();
|
|
352
370
|
if (token.length > 0) {
|
|
353
371
|
const verifyResult = validateAndConsumeChallenge(
|
|
354
|
-
|
|
372
|
+
assistantId,
|
|
355
373
|
sourceChannel,
|
|
356
374
|
token,
|
|
357
375
|
body.senderExternalUserId,
|
|
@@ -384,11 +402,15 @@ export async function handleChannelInbound(
|
|
|
384
402
|
// Determine whether the sender is the guardian for this channel.
|
|
385
403
|
// When a guardian binding exists, non-guardian actors get stricter
|
|
386
404
|
// side-effect controls and their approvals route to the guardian's chat.
|
|
405
|
+
//
|
|
406
|
+
// Guardian enforcement runs independently of CHANNEL_APPROVALS_ENABLED.
|
|
407
|
+
// The approval flag only gates the interactive approval prompting UX;
|
|
408
|
+
// actor-role resolution and fail-closed denial are always active.
|
|
387
409
|
let guardianCtx: GuardianContext = { actorRole: 'guardian' };
|
|
388
|
-
if (
|
|
389
|
-
const senderIsGuardian = isGuardian(
|
|
410
|
+
if (body.senderExternalUserId) {
|
|
411
|
+
const senderIsGuardian = isGuardian(assistantId, sourceChannel, body.senderExternalUserId);
|
|
390
412
|
if (!senderIsGuardian) {
|
|
391
|
-
const binding = getGuardianBinding(
|
|
413
|
+
const binding = getGuardianBinding(assistantId, sourceChannel);
|
|
392
414
|
if (binding) {
|
|
393
415
|
const requesterLabel = body.senderUsername
|
|
394
416
|
? `@${body.senderUsername}`
|
|
@@ -406,11 +428,25 @@ export async function handleChannelInbound(
|
|
|
406
428
|
// unverified. Sensitive actions will be auto-denied (fail-closed).
|
|
407
429
|
guardianCtx = {
|
|
408
430
|
actorRole: 'unverified_channel',
|
|
431
|
+
denialReason: 'no_binding',
|
|
409
432
|
requesterExternalUserId: body.senderExternalUserId,
|
|
410
433
|
requesterChatId: externalChatId,
|
|
411
434
|
};
|
|
412
435
|
}
|
|
413
436
|
}
|
|
437
|
+
} else {
|
|
438
|
+
// No sender identity available — fail-closed when guardian enforcement
|
|
439
|
+
// is active for this channel. If a binding exists, unknown actors must
|
|
440
|
+
// not be granted default guardian permissions.
|
|
441
|
+
const binding = getGuardianBinding(assistantId, sourceChannel);
|
|
442
|
+
if (binding) {
|
|
443
|
+
guardianCtx = {
|
|
444
|
+
actorRole: 'unverified_channel',
|
|
445
|
+
denialReason: 'no_identity',
|
|
446
|
+
requesterExternalUserId: undefined,
|
|
447
|
+
requesterChatId: externalChatId,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
414
450
|
}
|
|
415
451
|
|
|
416
452
|
// ── Approval interception (gated behind feature flag) ──
|
|
@@ -431,6 +467,7 @@ export async function handleChannelInbound(
|
|
|
431
467
|
bearerToken,
|
|
432
468
|
orchestrator: runOrchestrator,
|
|
433
469
|
guardianCtx,
|
|
470
|
+
assistantId,
|
|
434
471
|
});
|
|
435
472
|
|
|
436
473
|
if (approvalResult.handled) {
|
|
@@ -503,6 +540,7 @@ export async function handleChannelInbound(
|
|
|
503
540
|
replyCallbackUrl,
|
|
504
541
|
bearerToken,
|
|
505
542
|
guardianCtx,
|
|
543
|
+
assistantId,
|
|
506
544
|
});
|
|
507
545
|
} else {
|
|
508
546
|
// Fire-and-forget: process the message and deliver the reply in the background.
|
|
@@ -599,6 +637,22 @@ const RUN_POLL_MAX_WAIT_MS = 300_000; // 5 minutes
|
|
|
599
637
|
const POST_DECISION_POLL_INTERVAL_MS = 500;
|
|
600
638
|
const POST_DECISION_POLL_MAX_WAIT_MS = RUN_POLL_MAX_WAIT_MS;
|
|
601
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Override the poll max-wait for tests. When set, used in place of
|
|
642
|
+
* RUN_POLL_MAX_WAIT_MS so tests can exercise timeout paths without
|
|
643
|
+
* waiting 5 minutes.
|
|
644
|
+
*/
|
|
645
|
+
let testPollMaxWaitOverride: number | null = null;
|
|
646
|
+
|
|
647
|
+
/** @internal — test-only: set an override for the poll max-wait. */
|
|
648
|
+
export function _setTestPollMaxWait(ms: number | null): void {
|
|
649
|
+
testPollMaxWaitOverride = ms;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function getEffectivePollMaxWait(): number {
|
|
653
|
+
return testPollMaxWaitOverride ?? RUN_POLL_MAX_WAIT_MS;
|
|
654
|
+
}
|
|
655
|
+
|
|
602
656
|
interface ApprovalProcessingParams {
|
|
603
657
|
orchestrator: RunOrchestrator;
|
|
604
658
|
conversationId: string;
|
|
@@ -610,6 +664,7 @@ interface ApprovalProcessingParams {
|
|
|
610
664
|
replyCallbackUrl: string;
|
|
611
665
|
bearerToken?: string;
|
|
612
666
|
guardianCtx: GuardianContext;
|
|
667
|
+
assistantId: string;
|
|
613
668
|
}
|
|
614
669
|
|
|
615
670
|
/**
|
|
@@ -636,6 +691,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
636
691
|
replyCallbackUrl,
|
|
637
692
|
bearerToken,
|
|
638
693
|
guardianCtx,
|
|
694
|
+
assistantId,
|
|
639
695
|
} = params;
|
|
640
696
|
|
|
641
697
|
const isNonGuardian = guardianCtx.actorRole === 'non-guardian';
|
|
@@ -656,9 +712,10 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
656
712
|
// Poll the run until it reaches a terminal state, delivering approval
|
|
657
713
|
// prompts when it transitions to needs_confirmation.
|
|
658
714
|
const startTime = Date.now();
|
|
715
|
+
const pollMaxWait = getEffectivePollMaxWait();
|
|
659
716
|
let lastStatus = run.status;
|
|
660
717
|
|
|
661
|
-
while (Date.now() - startTime <
|
|
718
|
+
while (Date.now() - startTime < pollMaxWait) {
|
|
662
719
|
await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
|
|
663
720
|
|
|
664
721
|
const current = orchestrator.getRun(run.id);
|
|
@@ -668,12 +725,15 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
668
725
|
const pending = getPendingConfirmationsByConversation(conversationId);
|
|
669
726
|
|
|
670
727
|
if (isUnverifiedChannel && pending.length > 0) {
|
|
671
|
-
//
|
|
728
|
+
// Unverified channel — auto-deny the sensitive action (fail-closed).
|
|
672
729
|
handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
|
|
730
|
+
const denialText = guardianCtx.denialReason === 'no_identity'
|
|
731
|
+
? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied. Please ensure your messaging client sends user identity information.`
|
|
732
|
+
: `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied. Please ask an administrator to configure a guardian.`;
|
|
673
733
|
try {
|
|
674
734
|
await deliverChannelReply(replyCallbackUrl, {
|
|
675
735
|
chatId: externalChatId,
|
|
676
|
-
text:
|
|
736
|
+
text: denialText,
|
|
677
737
|
}, bearerToken);
|
|
678
738
|
} catch (err) {
|
|
679
739
|
log.error({ err, runId: run.id }, 'Failed to deliver unverified-channel denial notice');
|
|
@@ -691,6 +751,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
691
751
|
const approvalReqRecord = createApprovalRequest({
|
|
692
752
|
runId: run.id,
|
|
693
753
|
conversationId,
|
|
754
|
+
assistantId,
|
|
694
755
|
channel: sourceChannel,
|
|
695
756
|
requesterExternalUserId: guardianCtx.requesterExternalUserId ?? '',
|
|
696
757
|
requesterChatId: guardianCtx.requesterChatId ?? externalChatId,
|
|
@@ -765,7 +826,14 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
765
826
|
bearerToken,
|
|
766
827
|
);
|
|
767
828
|
} catch (err) {
|
|
768
|
-
|
|
829
|
+
// Fail-closed: if we cannot deliver the approval prompt, the
|
|
830
|
+
// user will never see it and the run would hang indefinitely
|
|
831
|
+
// in needs_confirmation. Auto-deny to avoid silent wait states.
|
|
832
|
+
log.error(
|
|
833
|
+
{ err, runId: run.id, conversationId },
|
|
834
|
+
'Failed to deliver standard approval prompt; auto-denying (fail-closed)',
|
|
835
|
+
);
|
|
836
|
+
handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
|
|
769
837
|
}
|
|
770
838
|
}
|
|
771
839
|
}
|
|
@@ -792,8 +860,19 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
792
860
|
|
|
793
861
|
channelDeliveryStore.markProcessed(eventId);
|
|
794
862
|
|
|
795
|
-
// Deliver the final assistant reply
|
|
796
|
-
|
|
863
|
+
// Deliver the final assistant reply exactly once. The post-decision
|
|
864
|
+
// poll in schedulePostDecisionDelivery races with this path; the
|
|
865
|
+
// claimRunDelivery guard ensures only the winner sends the reply.
|
|
866
|
+
// If delivery fails, release the claim so the other poller can retry
|
|
867
|
+
// rather than permanently losing the reply.
|
|
868
|
+
if (channelDeliveryStore.claimRunDelivery(run.id)) {
|
|
869
|
+
try {
|
|
870
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
871
|
+
} catch (deliveryErr) {
|
|
872
|
+
channelDeliveryStore.resetRunDeliveryClaim(run.id);
|
|
873
|
+
throw deliveryErr;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
797
876
|
|
|
798
877
|
// If this was a non-guardian run that went through guardian approval,
|
|
799
878
|
// also notify the guardian's chat about the outcome.
|
|
@@ -823,7 +902,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
823
902
|
// needs_secret, or disappeared). Record a processing failure so the
|
|
824
903
|
// retry/dead-letter machinery can handle it.
|
|
825
904
|
const timeoutErr = new Error(
|
|
826
|
-
`Approval poll timeout: run did not reach terminal state within ${
|
|
905
|
+
`Approval poll timeout: run did not reach terminal state within ${pollMaxWait}ms (status: ${finalRun?.status ?? 'null'})`,
|
|
827
906
|
);
|
|
828
907
|
log.warn(
|
|
829
908
|
{ runId: run.id, status: finalRun?.status, conversationId },
|
|
@@ -853,6 +932,7 @@ interface ApprovalInterceptionParams {
|
|
|
853
932
|
bearerToken?: string;
|
|
854
933
|
orchestrator: RunOrchestrator;
|
|
855
934
|
guardianCtx: GuardianContext;
|
|
935
|
+
assistantId: string;
|
|
856
936
|
}
|
|
857
937
|
|
|
858
938
|
interface ApprovalInterceptionResult {
|
|
@@ -884,6 +964,7 @@ async function handleApprovalInterception(
|
|
|
884
964
|
bearerToken,
|
|
885
965
|
orchestrator,
|
|
886
966
|
guardianCtx,
|
|
967
|
+
assistantId,
|
|
887
968
|
} = params;
|
|
888
969
|
|
|
889
970
|
// ── Guardian approval decision path ──
|
|
@@ -909,14 +990,14 @@ async function handleApprovalInterception(
|
|
|
909
990
|
// the decision resolves to exactly the right approval even when
|
|
910
991
|
// multiple approvals target the same guardian chat.
|
|
911
992
|
let guardianApproval = decision?.runId
|
|
912
|
-
? getPendingApprovalByRunAndGuardianChat(decision.runId, sourceChannel, externalChatId)
|
|
993
|
+
? getPendingApprovalByRunAndGuardianChat(decision.runId, sourceChannel, externalChatId, assistantId)
|
|
913
994
|
: null;
|
|
914
995
|
|
|
915
996
|
// For plain-text decisions (no run ID), check how many pending
|
|
916
997
|
// approvals exist for this guardian chat. If there are multiple,
|
|
917
998
|
// the guardian must use buttons to disambiguate.
|
|
918
999
|
if (!guardianApproval && decision && !decision.runId) {
|
|
919
|
-
const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId);
|
|
1000
|
+
const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId);
|
|
920
1001
|
if (allPending.length > 1) {
|
|
921
1002
|
try {
|
|
922
1003
|
await deliverChannelReply(replyCallbackUrl, {
|
|
@@ -936,7 +1017,7 @@ async function handleApprovalInterception(
|
|
|
936
1017
|
// Fall back to the single-result lookup for non-decision messages
|
|
937
1018
|
// (reminder path) or when the scoped lookup found nothing.
|
|
938
1019
|
if (!guardianApproval && !decision) {
|
|
939
|
-
guardianApproval = getPendingApprovalByGuardianChat(sourceChannel, externalChatId);
|
|
1020
|
+
guardianApproval = getPendingApprovalByGuardianChat(sourceChannel, externalChatId, assistantId);
|
|
940
1021
|
}
|
|
941
1022
|
|
|
942
1023
|
if (guardianApproval) {
|
|
@@ -1056,16 +1137,19 @@ async function handleApprovalInterception(
|
|
|
1056
1137
|
const pendingPrompt = getChannelApprovalPrompt(conversationId);
|
|
1057
1138
|
if (!pendingPrompt) return { handled: false };
|
|
1058
1139
|
|
|
1059
|
-
// When the sender is from an unverified channel
|
|
1060
|
-
//
|
|
1140
|
+
// When the sender is from an unverified channel, auto-deny any pending
|
|
1141
|
+
// confirmation and block self-approval.
|
|
1061
1142
|
if (guardianCtx.actorRole === 'unverified_channel') {
|
|
1062
1143
|
const pending = getPendingConfirmationsByConversation(conversationId);
|
|
1063
1144
|
if (pending.length > 0) {
|
|
1064
1145
|
handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
|
|
1146
|
+
const denialText = guardianCtx.denialReason === 'no_identity'
|
|
1147
|
+
? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied.`
|
|
1148
|
+
: `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied.`;
|
|
1065
1149
|
try {
|
|
1066
1150
|
await deliverChannelReply(replyCallbackUrl, {
|
|
1067
1151
|
chatId: externalChatId,
|
|
1068
|
-
text:
|
|
1152
|
+
text: denialText,
|
|
1069
1153
|
}, bearerToken);
|
|
1070
1154
|
} catch (err) {
|
|
1071
1155
|
log.error({ err, conversationId }, 'Failed to deliver unverified-channel denial notice during interception');
|
|
@@ -1153,8 +1237,8 @@ async function handleApprovalInterception(
|
|
|
1153
1237
|
// Schedule a background poll for run terminal state and deliver the reply.
|
|
1154
1238
|
// This handles the case where the original poll in
|
|
1155
1239
|
// processChannelMessageWithApprovals has already exited due to timeout.
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1240
|
+
// The claimRunDelivery guard ensures at-most-once delivery when both
|
|
1241
|
+
// pollers race to terminal state.
|
|
1158
1242
|
if (result.applied && result.runId) {
|
|
1159
1243
|
schedulePostDecisionDelivery(
|
|
1160
1244
|
orchestrator,
|
|
@@ -1201,9 +1285,9 @@ async function handleApprovalInterception(
|
|
|
1201
1285
|
* handles the case where the original poll in `processChannelMessageWithApprovals`
|
|
1202
1286
|
* has already exited due to the 5-minute timeout.
|
|
1203
1287
|
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1288
|
+
* Uses the same `claimRunDelivery` guard as the main poll to guarantee
|
|
1289
|
+
* at-most-once delivery: whichever poller reaches terminal state first
|
|
1290
|
+
* claims the delivery, and the other silently skips it.
|
|
1207
1291
|
*/
|
|
1208
1292
|
function schedulePostDecisionDelivery(
|
|
1209
1293
|
orchestrator: RunOrchestrator,
|
|
@@ -1221,7 +1305,14 @@ function schedulePostDecisionDelivery(
|
|
|
1221
1305
|
const current = orchestrator.getRun(runId);
|
|
1222
1306
|
if (!current) break;
|
|
1223
1307
|
if (current.status === 'completed' || current.status === 'failed') {
|
|
1224
|
-
|
|
1308
|
+
if (channelDeliveryStore.claimRunDelivery(runId)) {
|
|
1309
|
+
try {
|
|
1310
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
1311
|
+
} catch (deliveryErr) {
|
|
1312
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
1313
|
+
throw deliveryErr;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1225
1316
|
return;
|
|
1226
1317
|
}
|
|
1227
1318
|
}
|
package/src/skills/clawhub.ts
CHANGED
|
@@ -163,6 +163,8 @@ export interface ClawhubSearchResultItem {
|
|
|
163
163
|
installs: number;
|
|
164
164
|
version: string;
|
|
165
165
|
createdAt: number;
|
|
166
|
+
/** Where this skill comes from: "vellum" (first-party) or "clawhub" (community). */
|
|
167
|
+
source: 'vellum' | 'clawhub';
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
export interface ClawhubSearchResult {
|
|
@@ -273,10 +275,10 @@ export async function clawhubSearch(query: string): Promise<ClawhubSearchResult>
|
|
|
273
275
|
try {
|
|
274
276
|
const parsed = JSON.parse(result.stdout);
|
|
275
277
|
if (Array.isArray(parsed)) {
|
|
276
|
-
return { skills: parsed };
|
|
278
|
+
return { skills: parsed.map((s: ClawhubSearchResultItem) => ({ ...s, source: s.source ?? 'clawhub' as const })) };
|
|
277
279
|
}
|
|
278
280
|
if (parsed.skills && Array.isArray(parsed.skills)) {
|
|
279
|
-
return parsed as
|
|
281
|
+
return { skills: parsed.skills.map((s: ClawhubSearchResultItem) => ({ ...s, source: s.source ?? 'clawhub' as const })) };
|
|
280
282
|
}
|
|
281
283
|
} catch {
|
|
282
284
|
// CLI outputs text: "slug vVersion DisplayName (score)"
|
|
@@ -296,6 +298,7 @@ export async function clawhubSearch(query: string): Promise<ClawhubSearchResult>
|
|
|
296
298
|
stars: 0,
|
|
297
299
|
installs: 0,
|
|
298
300
|
createdAt: 0,
|
|
301
|
+
source: 'clawhub',
|
|
299
302
|
});
|
|
300
303
|
}
|
|
301
304
|
}
|
|
@@ -330,6 +333,7 @@ export async function clawhubExplore(opts?: { limit?: number; sort?: string }):
|
|
|
330
333
|
installs: (item.stats as Record<string, number>)?.installsAllTime ?? 0,
|
|
331
334
|
version: (item.tags as Record<string, string>)?.latest ?? '',
|
|
332
335
|
createdAt: (item.createdAt as number) ?? 0,
|
|
336
|
+
source: 'clawhub',
|
|
333
337
|
}));
|
|
334
338
|
return { skills };
|
|
335
339
|
} catch {
|
package/src/subagent/manager.ts
CHANGED
|
@@ -482,10 +482,13 @@ export class SubagentManager {
|
|
|
482
482
|
let message: string;
|
|
483
483
|
|
|
484
484
|
if (outcome === 'completed') {
|
|
485
|
+
const silent = config.sendResultToUser === false;
|
|
485
486
|
message =
|
|
486
487
|
`[Subagent "${config.label}" completed]\n\n` +
|
|
487
488
|
`Use subagent_read with subagent_id "${config.id}" to retrieve the full output.\n` +
|
|
488
|
-
|
|
489
|
+
(silent
|
|
490
|
+
? `This subagent was spawned for internal processing. Read the result for your own use but do NOT share it with the user.\nDo NOT re-spawn this subagent.`
|
|
491
|
+
: `Do NOT re-spawn this subagent — just read and share the results.`);
|
|
489
492
|
} else {
|
|
490
493
|
const error = managed.state.error ?? 'Unknown error';
|
|
491
494
|
message =
|
package/src/subagent/types.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface SubagentConfig {
|
|
|
42
42
|
systemPromptOverride?: string;
|
|
43
43
|
/** Optional skill IDs to pre-activate on the subagent session. */
|
|
44
44
|
preactivatedSkillIds?: string[];
|
|
45
|
+
/** Whether the parent should present the result to the user. Defaults to true. */
|
|
46
|
+
sendResultToUser?: boolean;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// ── State (runtime) ─────────────────────────────────────────────────────
|
|
@@ -15,7 +15,7 @@ function getVellumSkillsDir(): string {
|
|
|
15
15
|
return join(import.meta.dir, '..', '..', 'config', 'vellum-skills');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
interface CatalogEntry {
|
|
18
|
+
export interface CatalogEntry {
|
|
19
19
|
id: string;
|
|
20
20
|
name: string;
|
|
21
21
|
description: string;
|
|
@@ -88,7 +88,7 @@ function parseCatalogEntry(directory: string): CatalogEntry | null {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function listCatalogEntries(): CatalogEntry[] {
|
|
91
|
+
export function listCatalogEntries(): CatalogEntry[] {
|
|
92
92
|
const catalogDir = getVellumSkillsDir();
|
|
93
93
|
if (!existsSync(catalogDir)) return [];
|
|
94
94
|
|
|
@@ -107,6 +107,49 @@ function listCatalogEntries(): CatalogEntry[] {
|
|
|
107
107
|
return entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Install a skill from the vellum-skills catalog by ID.
|
|
112
|
+
* Returns { success, skillName, error }.
|
|
113
|
+
*/
|
|
114
|
+
export function installFromVellumCatalog(skillId: string): { success: boolean; skillName?: string; error?: string } {
|
|
115
|
+
const catalogDir = getVellumSkillsDir();
|
|
116
|
+
const skillDir = join(catalogDir, skillId.trim());
|
|
117
|
+
const skillFilePath = join(skillDir, 'SKILL.md');
|
|
118
|
+
|
|
119
|
+
if (!existsSync(skillFilePath)) {
|
|
120
|
+
return { success: false, error: `Skill "${skillId}" not found in the Vellum catalog` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const content = readFileSync(skillFilePath, 'utf-8');
|
|
124
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
125
|
+
if (!match) {
|
|
126
|
+
return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const entry = parseCatalogEntry(skillDir);
|
|
130
|
+
if (!entry) {
|
|
131
|
+
return { success: false, error: `Skill "${skillId}" has invalid SKILL.md` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const bodyMarkdown = content.slice(match[0].length);
|
|
135
|
+
const result = createManagedSkill({
|
|
136
|
+
id: entry.id,
|
|
137
|
+
name: entry.name,
|
|
138
|
+
description: entry.description,
|
|
139
|
+
bodyMarkdown,
|
|
140
|
+
emoji: entry.emoji,
|
|
141
|
+
includes: entry.includes,
|
|
142
|
+
overwrite: true,
|
|
143
|
+
addToIndex: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!result.created) {
|
|
147
|
+
return { success: false, error: result.error };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { success: true, skillName: entry.id };
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
class VellumSkillsCatalogTool implements Tool {
|
|
111
154
|
name = 'vellum_skills_catalog';
|
|
112
155
|
description = 'List and install Vellum-provided skills from the first-party catalog';
|
|
@@ -8,6 +8,7 @@ export async function executeSubagentSpawn(
|
|
|
8
8
|
const label = input.label as string;
|
|
9
9
|
const objective = input.objective as string;
|
|
10
10
|
const extraContext = input.context as string | undefined;
|
|
11
|
+
const sendResultToUser = input.send_result_to_user !== false;
|
|
11
12
|
|
|
12
13
|
if (!label || !objective) {
|
|
13
14
|
return { content: 'Both "label" and "objective" are required.', isError: true };
|
|
@@ -26,6 +27,7 @@ export async function executeSubagentSpawn(
|
|
|
26
27
|
label,
|
|
27
28
|
objective,
|
|
28
29
|
context: extraContext,
|
|
30
|
+
sendResultToUser,
|
|
29
31
|
},
|
|
30
32
|
sendToClient as (msg: unknown) => void,
|
|
31
33
|
);
|