@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -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/deliver', 'token');
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/deliver', 'token');
2348
+ sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test', 'token');
2343
2349
 
2344
2350
  await new Promise((resolve) => setTimeout(resolve, 200));
2345
2351
 
@@ -1358,7 +1358,7 @@ export function handleGuardianVerification(
1358
1358
  ctx.send(socket, {
1359
1359
  type: 'guardian_verification_response',
1360
1360
  success: true,
1361
- bound: !revoked,
1361
+ bound: false,
1362
1362
  });
1363
1363
  } else {
1364
1364
  ctx.send(socket, {
@@ -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
- invalidAttempts: row.invalidAttempts,
541
- windowStartedAt: row.windowStartedAt,
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. Creates the rate-limit row if
576
- * it does not yet exist, or increments the counter within the current
577
- * throttling window.
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
- // If the throttling window has elapsed, reset the counter
595
- const windowExpired = now - existing.windowStartedAt > windowMs;
596
- const newAttempts = windowExpired ? 1 : existing.invalidAttempts + 1;
597
- const newWindowStart = windowExpired ? now : existing.windowStartedAt;
598
- const newLockedUntil = newAttempts >= maxAttempts ? now + lockoutMs : existing.lockedUntil;
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
- invalidAttempts: newAttempts,
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
- invalidAttempts: newAttempts,
613
- windowStartedAt: newWindowStart,
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
- invalidAttempts: 1,
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
- invalidAttempts: 0,
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
- invalid_attempts INTEGER NOT NULL DEFAULT 0,
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);
@@ -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
- invalidAttempts: integer('invalid_attempts').notNull().default(0),
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
- replyCallbackUrl: string,
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(replyCallbackUrl, {
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(replyCallbackUrl, {
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
- replyCallbackUrl: string,
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, replyCallbackUrl, bearerToken);
1396
+ sweepExpiredGuardianApprovals(orchestrator, gatewayBaseUrl, bearerToken);
1390
1397
  } catch (err) {
1391
1398
  log.error({ err }, 'Guardian expiry sweep failed');
1392
1399
  }