@vellumai/assistant 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/channel-approval-routes.test.ts +9 -3
- package/src/daemon/handlers/config.ts +1 -1
- package/src/memory/channel-guardian-store.ts +37 -19
- package/src/memory/db.ts +6 -2
- package/src/memory/schema.ts +1 -2
- package/src/runtime/http-server.ts +19 -0
- package/src/runtime/routes/channel-routes.ts +12 -5
package/package.json
CHANGED
|
@@ -2281,8 +2281,8 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
2281
2281
|
|
|
2282
2282
|
const orchestrator = makeMockOrchestrator();
|
|
2283
2283
|
|
|
2284
|
-
// Run the sweep
|
|
2285
|
-
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test
|
|
2284
|
+
// Run the sweep — pass the gateway base URL (not a full /deliver/<channel> URL)
|
|
2285
|
+
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test', 'token');
|
|
2286
2286
|
|
|
2287
2287
|
// Wait for async notifications
|
|
2288
2288
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
@@ -2313,6 +2313,12 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
2313
2313
|
);
|
|
2314
2314
|
expect(guardianNotify.length).toBeGreaterThanOrEqual(1);
|
|
2315
2315
|
|
|
2316
|
+
// Verify the delivery URL is constructed per-channel (telegram in this case)
|
|
2317
|
+
const allDeliverCalls = deliverSpy.mock.calls;
|
|
2318
|
+
for (const call of allDeliverCalls) {
|
|
2319
|
+
expect(call[0]).toBe('https://gateway.test/deliver/telegram');
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2316
2322
|
deliverSpy.mockRestore();
|
|
2317
2323
|
});
|
|
2318
2324
|
|
|
@@ -2339,7 +2345,7 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
2339
2345
|
|
|
2340
2346
|
const orchestrator = makeMockOrchestrator();
|
|
2341
2347
|
|
|
2342
|
-
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test
|
|
2348
|
+
sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test', 'token');
|
|
2343
2349
|
|
|
2344
2350
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2345
2351
|
|
|
@@ -523,22 +523,34 @@ export interface VerificationRateLimit {
|
|
|
523
523
|
channel: string;
|
|
524
524
|
actorExternalUserId: string;
|
|
525
525
|
actorChatId: string;
|
|
526
|
+
/** Individual attempt timestamps (epoch-ms) within the sliding window. */
|
|
527
|
+
attemptTimestamps: number[];
|
|
528
|
+
/** Derived count of attempts currently inside the window (convenience). */
|
|
526
529
|
invalidAttempts: number;
|
|
527
|
-
windowStartedAt: number;
|
|
528
530
|
lockedUntil: number | null;
|
|
529
531
|
createdAt: number;
|
|
530
532
|
updatedAt: number;
|
|
531
533
|
}
|
|
532
534
|
|
|
535
|
+
function parseTimestamps(json: string): number[] {
|
|
536
|
+
try {
|
|
537
|
+
const arr = JSON.parse(json);
|
|
538
|
+
return Array.isArray(arr) ? arr : [];
|
|
539
|
+
} catch {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
533
544
|
function rowToRateLimit(row: typeof channelGuardianRateLimits.$inferSelect): VerificationRateLimit {
|
|
545
|
+
const timestamps = parseTimestamps(row.attemptTimestampsJson);
|
|
534
546
|
return {
|
|
535
547
|
id: row.id,
|
|
536
548
|
assistantId: row.assistantId,
|
|
537
549
|
channel: row.channel,
|
|
538
550
|
actorExternalUserId: row.actorExternalUserId,
|
|
539
551
|
actorChatId: row.actorChatId,
|
|
540
|
-
|
|
541
|
-
|
|
552
|
+
attemptTimestamps: timestamps,
|
|
553
|
+
invalidAttempts: timestamps.length,
|
|
542
554
|
lockedUntil: row.lockedUntil,
|
|
543
555
|
createdAt: row.createdAt,
|
|
544
556
|
updatedAt: row.updatedAt,
|
|
@@ -572,9 +584,13 @@ export function getRateLimit(
|
|
|
572
584
|
}
|
|
573
585
|
|
|
574
586
|
/**
|
|
575
|
-
* Record an invalid verification attempt
|
|
576
|
-
*
|
|
577
|
-
*
|
|
587
|
+
* Record an invalid verification attempt using a true sliding window.
|
|
588
|
+
*
|
|
589
|
+
* Each individual attempt timestamp is stored; on every new attempt we
|
|
590
|
+
* discard timestamps older than `windowMs`, append the current one, and
|
|
591
|
+
* check whether the count exceeds `maxAttempts`. This avoids the
|
|
592
|
+
* inactivity-timeout pitfall where attempts spaced just under the window
|
|
593
|
+
* accumulate indefinitely.
|
|
578
594
|
*/
|
|
579
595
|
export function recordInvalidAttempt(
|
|
580
596
|
assistantId: string,
|
|
@@ -587,20 +603,23 @@ export function recordInvalidAttempt(
|
|
|
587
603
|
): VerificationRateLimit {
|
|
588
604
|
const db = getDb();
|
|
589
605
|
const now = Date.now();
|
|
606
|
+
const cutoff = now - windowMs;
|
|
590
607
|
|
|
591
608
|
const existing = getRateLimit(assistantId, channel, actorExternalUserId, actorChatId);
|
|
592
609
|
|
|
593
610
|
if (existing) {
|
|
594
|
-
//
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const newLockedUntil =
|
|
611
|
+
// Keep only timestamps within the sliding window, then add the new one
|
|
612
|
+
const recentTimestamps = existing.attemptTimestamps.filter((ts) => ts > cutoff);
|
|
613
|
+
recentTimestamps.push(now);
|
|
614
|
+
|
|
615
|
+
const newLockedUntil =
|
|
616
|
+
recentTimestamps.length >= maxAttempts ? now + lockoutMs : existing.lockedUntil;
|
|
617
|
+
|
|
618
|
+
const timestampsJson = JSON.stringify(recentTimestamps);
|
|
599
619
|
|
|
600
620
|
db.update(channelGuardianRateLimits)
|
|
601
621
|
.set({
|
|
602
|
-
|
|
603
|
-
windowStartedAt: newWindowStart,
|
|
622
|
+
attemptTimestampsJson: timestampsJson,
|
|
604
623
|
lockedUntil: newLockedUntil,
|
|
605
624
|
updatedAt: now,
|
|
606
625
|
})
|
|
@@ -609,8 +628,8 @@ export function recordInvalidAttempt(
|
|
|
609
628
|
|
|
610
629
|
return {
|
|
611
630
|
...existing,
|
|
612
|
-
|
|
613
|
-
|
|
631
|
+
attemptTimestamps: recentTimestamps,
|
|
632
|
+
invalidAttempts: recentTimestamps.length,
|
|
614
633
|
lockedUntil: newLockedUntil,
|
|
615
634
|
updatedAt: now,
|
|
616
635
|
};
|
|
@@ -618,6 +637,7 @@ export function recordInvalidAttempt(
|
|
|
618
637
|
|
|
619
638
|
// First attempt — create the row
|
|
620
639
|
const id = uuid();
|
|
640
|
+
const timestamps = [now];
|
|
621
641
|
const lockedUntil = 1 >= maxAttempts ? now + lockoutMs : null;
|
|
622
642
|
const row = {
|
|
623
643
|
id,
|
|
@@ -625,8 +645,7 @@ export function recordInvalidAttempt(
|
|
|
625
645
|
channel,
|
|
626
646
|
actorExternalUserId,
|
|
627
647
|
actorChatId,
|
|
628
|
-
|
|
629
|
-
windowStartedAt: now,
|
|
648
|
+
attemptTimestampsJson: JSON.stringify(timestamps),
|
|
630
649
|
lockedUntil,
|
|
631
650
|
createdAt: now,
|
|
632
651
|
updatedAt: now,
|
|
@@ -652,9 +671,8 @@ export function resetRateLimit(
|
|
|
652
671
|
|
|
653
672
|
db.update(channelGuardianRateLimits)
|
|
654
673
|
.set({
|
|
655
|
-
|
|
674
|
+
attemptTimestampsJson: '[]',
|
|
656
675
|
lockedUntil: null,
|
|
657
|
-
windowStartedAt: now,
|
|
658
676
|
updatedAt: now,
|
|
659
677
|
})
|
|
660
678
|
.where(
|
package/src/memory/db.ts
CHANGED
|
@@ -1001,14 +1001,18 @@ export function initializeDb(): void {
|
|
|
1001
1001
|
channel TEXT NOT NULL,
|
|
1002
1002
|
actor_external_user_id TEXT NOT NULL,
|
|
1003
1003
|
actor_chat_id TEXT NOT NULL,
|
|
1004
|
-
|
|
1005
|
-
window_started_at INTEGER NOT NULL,
|
|
1004
|
+
attempt_timestamps_json TEXT NOT NULL DEFAULT '[]',
|
|
1006
1005
|
locked_until INTEGER,
|
|
1007
1006
|
created_at INTEGER NOT NULL,
|
|
1008
1007
|
updated_at INTEGER NOT NULL
|
|
1009
1008
|
)
|
|
1010
1009
|
`);
|
|
1011
1010
|
|
|
1011
|
+
// Migration: add attempt_timestamps_json column for true sliding-window rate limiting.
|
|
1012
|
+
// The old invalid_attempts / window_started_at columns are left in place (SQLite
|
|
1013
|
+
// doesn't support DROP COLUMN in older versions) but are no longer read by the app.
|
|
1014
|
+
try { database.run(/*sql*/ `ALTER TABLE channel_guardian_rate_limits ADD COLUMN attempt_timestamps_json TEXT NOT NULL DEFAULT '[]'`); } catch { /* already exists */ }
|
|
1015
|
+
|
|
1012
1016
|
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`);
|
|
1013
1017
|
|
|
1014
1018
|
migrateMemoryFtsBackfill(database);
|
package/src/memory/schema.ts
CHANGED
|
@@ -669,8 +669,7 @@ export const channelGuardianRateLimits = sqliteTable('channel_guardian_rate_limi
|
|
|
669
669
|
channel: text('channel').notNull(),
|
|
670
670
|
actorExternalUserId: text('actor_external_user_id').notNull(),
|
|
671
671
|
actorChatId: text('actor_chat_id').notNull(),
|
|
672
|
-
|
|
673
|
-
windowStartedAt: integer('window_started_at').notNull(),
|
|
672
|
+
attemptTimestampsJson: text('attempt_timestamps_json').notNull().default('[]'),
|
|
674
673
|
lockedUntil: integer('locked_until'),
|
|
675
674
|
createdAt: integer('created_at').notNull(),
|
|
676
675
|
updatedAt: integer('updated_at').notNull(),
|
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
handleChannelDeliveryAck,
|
|
42
42
|
handleListDeadLetters,
|
|
43
43
|
handleReplayDeadLetters,
|
|
44
|
+
isChannelApprovalsEnabled,
|
|
45
|
+
startGuardianExpirySweep,
|
|
46
|
+
stopGuardianExpirySweep,
|
|
44
47
|
} from './routes/channel-routes.js';
|
|
45
48
|
import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
|
|
46
49
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
@@ -93,6 +96,15 @@ const log = getLogger('runtime-http');
|
|
|
93
96
|
const DEFAULT_PORT = 7821;
|
|
94
97
|
const DEFAULT_HOSTNAME = '127.0.0.1';
|
|
95
98
|
|
|
99
|
+
/** Resolve the gateway base URL for internal delivery callbacks. */
|
|
100
|
+
function getGatewayBaseUrl(): string {
|
|
101
|
+
if (process.env.GATEWAY_INTERNAL_BASE_URL) {
|
|
102
|
+
return process.env.GATEWAY_INTERNAL_BASE_URL.replace(/\/+$/, '');
|
|
103
|
+
}
|
|
104
|
+
const port = Number(process.env.GATEWAY_PORT) || 7830;
|
|
105
|
+
return `http://127.0.0.1:${port}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
/** Global hard cap on request body size (50 MB). Bun rejects larger payloads before they reach handlers. */
|
|
97
109
|
const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
|
|
98
110
|
|
|
@@ -399,6 +411,12 @@ export class RuntimeHttpServer {
|
|
|
399
411
|
}, 30_000);
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
// Start proactive guardian approval expiry sweep when approvals are enabled
|
|
415
|
+
if (isChannelApprovalsEnabled() && this.runOrchestrator) {
|
|
416
|
+
startGuardianExpirySweep(this.runOrchestrator, getGatewayBaseUrl(), this.bearerToken);
|
|
417
|
+
log.info('Guardian approval expiry sweep started');
|
|
418
|
+
}
|
|
419
|
+
|
|
402
420
|
// Startup guard: log gateway-only mode warnings
|
|
403
421
|
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
404
422
|
if (!isLoopbackHost(this.hostname)) {
|
|
@@ -409,6 +427,7 @@ export class RuntimeHttpServer {
|
|
|
409
427
|
}
|
|
410
428
|
|
|
411
429
|
async stop(): Promise<void> {
|
|
430
|
+
stopGuardianExpirySweep();
|
|
412
431
|
if (this.retrySweepTimer) {
|
|
413
432
|
clearInterval(this.retrySweepTimer);
|
|
414
433
|
this.retrySweepTimer = null;
|
|
@@ -1333,10 +1333,14 @@ let expirySweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
1333
1333
|
* and notify both the requester and guardian. This runs proactively on a
|
|
1334
1334
|
* timer so expired approvals are closed without waiting for follow-up
|
|
1335
1335
|
* traffic from either party.
|
|
1336
|
+
*
|
|
1337
|
+
* Accepts a `gatewayBaseUrl` rather than a fixed delivery URL so that
|
|
1338
|
+
* each approval's notification is routed to the correct channel-specific
|
|
1339
|
+
* endpoint (e.g. `/deliver/telegram`, `/deliver/sms`).
|
|
1336
1340
|
*/
|
|
1337
1341
|
export function sweepExpiredGuardianApprovals(
|
|
1338
1342
|
orchestrator: RunOrchestrator,
|
|
1339
|
-
|
|
1343
|
+
gatewayBaseUrl: string,
|
|
1340
1344
|
bearerToken?: string,
|
|
1341
1345
|
): void {
|
|
1342
1346
|
const expired = getExpiredPendingApprovals();
|
|
@@ -1351,8 +1355,11 @@ export function sweepExpiredGuardianApprovals(
|
|
|
1351
1355
|
};
|
|
1352
1356
|
handleChannelDecision(approval.conversationId, expiredDecision, orchestrator);
|
|
1353
1357
|
|
|
1358
|
+
// Construct the per-channel delivery URL from the approval's channel
|
|
1359
|
+
const deliverUrl = `${gatewayBaseUrl}/deliver/${approval.channel}`;
|
|
1360
|
+
|
|
1354
1361
|
// Notify the requester that the approval expired
|
|
1355
|
-
deliverChannelReply(
|
|
1362
|
+
deliverChannelReply(deliverUrl, {
|
|
1356
1363
|
chatId: approval.requesterChatId,
|
|
1357
1364
|
text: `Your guardian approval request for "${approval.toolName}" has expired and the action has been denied. Please try again.`,
|
|
1358
1365
|
}, bearerToken).catch((err) => {
|
|
@@ -1360,7 +1367,7 @@ export function sweepExpiredGuardianApprovals(
|
|
|
1360
1367
|
});
|
|
1361
1368
|
|
|
1362
1369
|
// Notify the guardian that the approval expired
|
|
1363
|
-
deliverChannelReply(
|
|
1370
|
+
deliverChannelReply(deliverUrl, {
|
|
1364
1371
|
chatId: approval.guardianChatId,
|
|
1365
1372
|
text: `The approval request for "${approval.toolName}" from user ${approval.requesterExternalUserId} has expired and was automatically denied.`,
|
|
1366
1373
|
}, bearerToken).catch((err) => {
|
|
@@ -1380,13 +1387,13 @@ export function sweepExpiredGuardianApprovals(
|
|
|
1380
1387
|
*/
|
|
1381
1388
|
export function startGuardianExpirySweep(
|
|
1382
1389
|
orchestrator: RunOrchestrator,
|
|
1383
|
-
|
|
1390
|
+
gatewayBaseUrl: string,
|
|
1384
1391
|
bearerToken?: string,
|
|
1385
1392
|
): void {
|
|
1386
1393
|
if (expirySweepTimer) return;
|
|
1387
1394
|
expirySweepTimer = setInterval(() => {
|
|
1388
1395
|
try {
|
|
1389
|
-
sweepExpiredGuardianApprovals(orchestrator,
|
|
1396
|
+
sweepExpiredGuardianApprovals(orchestrator, gatewayBaseUrl, bearerToken);
|
|
1390
1397
|
} catch (err) {
|
|
1391
1398
|
log.error({ err }, 'Guardian expiry sweep failed');
|
|
1392
1399
|
}
|