@andreprado/agentkit 0.1.0 → 0.1.1

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": "@andreprado/agentkit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
@@ -8,6 +8,10 @@ import type {
8
8
  WebhookVerificationResult,
9
9
  } from "../channels";
10
10
  import { AgentKitError } from "../errors";
11
+ import {
12
+ CHANNEL_SEND_TIMEOUT_MS,
13
+ classifySendRequestError,
14
+ } from "./provider-fetch";
11
15
 
12
16
  const DISCORD_INTERACTION_PING = 1;
13
17
  const DISCORD_INTERACTION_APPLICATION_COMMAND = 2;
@@ -331,16 +335,18 @@ export async function sendDiscordMessage(
331
335
 
332
336
  try {
333
337
  response = await fetcher(actualRequest.url, {
338
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
334
339
  method: actualRequest.method,
335
340
  headers: actualRequest.headers,
336
341
  body: JSON.stringify(actualRequest.body),
337
342
  });
338
343
  } catch (error) {
344
+ const failure = classifySendRequestError(error);
339
345
  return {
340
346
  ok: false,
341
- retryable: true,
342
- code: "channel_provider_unavailable",
343
- message: `Discord bot message request failed before a response: ${redactDiscordToken(
347
+ retryable: failure.retryable,
348
+ code: failure.code,
349
+ message: `Discord bot message request ${failure.reason}: ${redactDiscordToken(
344
350
  error instanceof Error ? error.message : String(error),
345
351
  input.secrets.DISCORD_BOT_TOKEN,
346
352
  )}`,
@@ -457,6 +463,7 @@ export async function sendDiscordInteractionFollowup(
457
463
 
458
464
  try {
459
465
  response = await fetcher(actualRequest.url, {
466
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
460
467
  method: actualRequest.method,
461
468
  headers: {
462
469
  "Content-Type": "application/json",
@@ -464,11 +471,12 @@ export async function sendDiscordInteractionFollowup(
464
471
  body: JSON.stringify(actualRequest.body),
465
472
  });
466
473
  } catch (error) {
474
+ const failure = classifySendRequestError(error);
467
475
  return {
468
476
  ok: false,
469
- retryable: true,
470
- code: "channel_provider_unavailable",
471
- message: `Discord follow-up request failed before a response: ${redactDiscordToken(
477
+ retryable: failure.retryable,
478
+ code: failure.code,
479
+ message: `Discord follow-up request ${failure.reason}: ${redactDiscordToken(
472
480
  error instanceof Error ? error.message : String(error),
473
481
  replyTarget.interactionToken,
474
482
  )}`,
@@ -1,16 +1,21 @@
1
1
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
- import { lookup as dnsLookup } from "node:dns/promises";
3
- import { request as httpsRequest } from "node:https";
4
- import type { LookupFunction } from "node:net";
5
- import { Readable } from "node:stream";
6
2
 
7
3
  import type { GenericWebhookOutputChannelConfig } from "../../index";
8
4
  import type { ChannelAdapter, ChannelSendInput, ChannelSendResult, RawWebhookEvent } from "../channels";
9
5
  import { AgentKitError } from "../errors";
6
+ import {
7
+ isBlockedOutboundHost,
8
+ pinnedHttpsFetch,
9
+ resolveOutboundAddresses,
10
+ resolvePublicOutboundUrl,
11
+ type OutboundResolver,
12
+ type ResolvedOutboundUrl,
13
+ } from "./net-guard";
10
14
 
11
15
  const DEFAULT_SECRET_NAME = "AGENTKIT_WEBHOOK_SECRET";
12
16
  const DEFAULT_OUTPUT_HMAC_HEADER = "X-AgentKit-Webhook-Signature";
13
17
  const MAX_OUTPUT_RESPONSE_BYTES = 64_000;
18
+ const GENERIC_WEBHOOK_OUTPUT_URL_LABEL = "Generic webhook output URL";
14
19
 
15
20
  export const genericWebhookChannelAdapter: ChannelAdapter = {
16
21
  type: "webhook",
@@ -152,12 +157,7 @@ type GenericWebhookOutputRequest = {
152
157
  };
153
158
 
154
159
  type GenericWebhookOutputFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
155
- type GenericWebhookOutputResolver = (hostname: string) => Promise<string[]>;
156
- type GenericWebhookOutputResolvedUrl = {
157
- href: string;
158
- address: string;
159
- family: 4 | 6;
160
- };
160
+ type GenericWebhookOutputResolver = OutboundResolver;
161
161
  type GenericWebhookOutputRequestInit = {
162
162
  method: "POST";
163
163
  headers: Record<string, string>;
@@ -218,10 +218,12 @@ export async function sendGenericWebhookOutputMessage(
218
218
  input: ChannelSendInput,
219
219
  channel: GenericWebhookOutputChannelConfig,
220
220
  fetcher?: GenericWebhookOutputFetch,
221
- resolver: GenericWebhookOutputResolver = resolveGenericWebhookOutputAddresses,
221
+ resolver: GenericWebhookOutputResolver = resolveOutboundAddresses,
222
222
  ): Promise<ChannelSendResult> {
223
223
  let request: GenericWebhookOutputRequest;
224
- let resolvedUrl: GenericWebhookOutputResolvedUrl;
224
+ let resolvedUrl: ResolvedOutboundUrl;
225
+ // Started before DNS resolution so a stalled resolver counts against the deadline.
226
+ const signal = AbortSignal.timeout(15_000);
225
227
 
226
228
  try {
227
229
  request = buildGenericWebhookOutputRequest(input, channel);
@@ -235,7 +237,7 @@ export async function sendGenericWebhookOutputMessage(
235
237
  };
236
238
  }
237
239
 
238
- resolvedUrl = await validateGenericWebhookOutputResolvedUrl(request.url, resolver);
240
+ resolvedUrl = await validateGenericWebhookOutputResolvedUrl(request.url, resolver, signal);
239
241
  } catch (error) {
240
242
  return genericWebhookOutputFailureFromError(error);
241
243
  }
@@ -246,13 +248,13 @@ export async function sendGenericWebhookOutputMessage(
246
248
  headers: request.headers,
247
249
  body: JSON.stringify(request.body),
248
250
  redirect: "manual",
249
- signal: AbortSignal.timeout(15_000),
251
+ signal,
250
252
  };
251
253
 
252
254
  try {
253
255
  response = fetcher
254
256
  ? await fetcher(resolvedUrl.href, requestInit)
255
- : await fetchGenericWebhookOutputResponse(resolvedUrl, requestInit);
257
+ : await pinnedHttpsFetch(resolvedUrl, requestInit, GENERIC_WEBHOOK_OUTPUT_URL_LABEL);
256
258
  } catch (error) {
257
259
  if (error instanceof AgentKitError) {
258
260
  return {
@@ -411,7 +413,7 @@ function validateGenericWebhookOutputUrl(rawUrl: string): string {
411
413
  throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL must not include credentials.");
412
414
  }
413
415
 
414
- if (isBlockedGenericWebhookOutputHost(url.hostname)) {
416
+ if (isBlockedOutboundHost(url.hostname)) {
415
417
  throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL host is not allowed.");
416
418
  }
417
419
 
@@ -421,317 +423,23 @@ function validateGenericWebhookOutputUrl(rawUrl: string): string {
421
423
  async function validateGenericWebhookOutputResolvedUrl(
422
424
  rawUrl: string,
423
425
  resolver: GenericWebhookOutputResolver,
424
- ): Promise<GenericWebhookOutputResolvedUrl> {
425
- const href = validateGenericWebhookOutputUrl(rawUrl);
426
- const url = new URL(href);
427
- let addresses: string[];
428
-
429
- try {
430
- addresses = await resolver(url.hostname);
431
- } catch (error) {
432
- throw new AgentKitError(
433
- "channel_provider_unavailable",
434
- `Generic webhook output URL host could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
435
- );
436
- }
437
-
438
- if (addresses.length === 0) {
439
- throw new AgentKitError("channel_provider_unavailable", "Generic webhook output URL host did not resolve.");
440
- }
441
-
442
- for (const address of addresses) {
443
- if (isBlockedGenericWebhookOutputHost(address)) {
444
- throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL host resolved to a blocked address.");
445
- }
446
- }
447
-
448
- const address = normalizeGenericWebhookOutputHost(addresses[0]);
449
-
450
- return {
451
- href,
452
- address,
453
- family: parseIpv4Octets(address) ? 4 : 6,
454
- };
455
- }
456
-
457
- function fetchGenericWebhookOutputResponse(
458
- resolvedUrl: GenericWebhookOutputResolvedUrl,
459
- init: GenericWebhookOutputRequestInit,
460
- ): Promise<Response> {
461
- const url = new URL(resolvedUrl.href);
462
- const lookup: LookupFunction = (_hostname, _options, callback) => {
463
- callback(null, resolvedUrl.address, resolvedUrl.family);
464
- };
465
-
466
- return new Promise((resolve, reject) => {
467
- const request = httpsRequest(
468
- url,
469
- {
470
- method: init.method,
471
- headers: init.headers,
472
- lookup,
473
- signal: init.signal,
474
- },
475
- (response) => {
476
- try {
477
- assertAllowedGenericWebhookOutputRemoteAddress(response.socket.remoteAddress);
478
- } catch (error) {
479
- response.destroy(error instanceof Error ? error : new Error(String(error)));
480
- reject(error);
481
- return;
482
- }
483
-
484
- resolve(
485
- new Response(Readable.toWeb(response) as unknown as ReadableStream<Uint8Array>, {
486
- status: response.statusCode ?? 502,
487
- headers: headersFromIncomingMessage(response.headers),
488
- }),
489
- );
490
- },
491
- );
492
-
493
- request.on("socket", (socket) => {
494
- const validateSocket = () => {
495
- try {
496
- assertAllowedGenericWebhookOutputRemoteAddress(socket.remoteAddress);
497
- } catch (error) {
498
- request.destroy(error instanceof Error ? error : new Error(String(error)));
499
- }
500
- };
501
-
502
- socket.once("connect", validateSocket);
503
- socket.once("secureConnect", validateSocket);
504
- });
505
- request.once("error", reject);
506
- request.end(init.body);
507
- });
508
- }
509
-
510
- function assertAllowedGenericWebhookOutputRemoteAddress(address: string | undefined): void {
511
- if (!address || isBlockedGenericWebhookOutputHost(address)) {
512
- throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL connected to a blocked address.");
513
- }
514
- }
515
-
516
- function headersFromIncomingMessage(headers: Record<string, string | string[] | number | undefined>): Headers {
517
- const result = new Headers();
518
-
519
- for (const [key, value] of Object.entries(headers)) {
520
- if (Array.isArray(value)) {
521
- for (const item of value) {
522
- result.append(key, item);
523
- }
524
- continue;
525
- }
526
-
527
- if (value !== undefined) {
528
- result.set(key, String(value));
529
- }
530
- }
531
-
532
- return result;
533
- }
534
-
535
- async function resolveGenericWebhookOutputAddresses(hostname: string): Promise<string[]> {
536
- const host = normalizeGenericWebhookOutputHost(hostname);
537
-
538
- if (parseIpv4Octets(host) || parseIpv6Segments(host)) {
539
- return [host];
540
- }
541
-
542
- const records = await dnsLookup(host, {
543
- all: true,
544
- verbatim: true,
545
- });
546
-
547
- return records.map((record) => record.address);
548
- }
549
-
550
- function isBlockedGenericWebhookOutputHost(hostname: string): boolean {
551
- const host = normalizeGenericWebhookOutputHost(hostname);
552
-
553
- if (
554
- host === "localhost" ||
555
- host.endsWith(".localhost") ||
556
- host === "metadata.google.internal"
557
- ) {
558
- return true;
559
- }
560
-
561
- const ipv4 = parseIpv4Octets(host);
562
-
563
- if (ipv4) {
564
- return isBlockedIpv4Octets(ipv4);
565
- }
566
-
567
- const ipv6 = parseIpv6Segments(host);
568
-
569
- if (ipv6) {
570
- return isBlockedIpv6Segments(ipv6);
571
- }
572
-
573
- return false;
574
- }
575
-
576
- function normalizeGenericWebhookOutputHost(hostname: string): string {
577
- return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
578
- }
579
-
580
- function parseIpv4Octets(host: string): number[] | undefined {
581
- const parts = host.split(".");
582
-
583
- if (parts.length !== 4) {
584
- return undefined;
585
- }
586
-
587
- const octets = parts.map((part) => Number(part));
588
-
589
- if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
590
- return undefined;
591
- }
592
-
593
- return octets;
426
+ signal: AbortSignal,
427
+ ): Promise<ResolvedOutboundUrl> {
428
+ return resolvePublicOutboundUrl(validateGenericWebhookOutputUrl(rawUrl), GENERIC_WEBHOOK_OUTPUT_URL_LABEL, resolver, signal);
594
429
  }
595
430
 
596
- function isBlockedIpv4Octets(parts: number[]): boolean {
597
- const [a, b] = parts;
598
431
 
599
- return (
600
- a === 0 ||
601
- a === 10 ||
602
- a === 127 ||
603
- (a === 100 && b >= 64 && b <= 127) ||
604
- (a === 169 && b === 254) ||
605
- (a === 172 && b >= 16 && b <= 31) ||
606
- (a === 192 && b === 168) ||
607
- (a === 198 && (b === 18 || b === 19)) ||
608
- a >= 224
609
- );
610
- }
611
432
 
612
- function parseIpv6Segments(host: string): number[] | undefined {
613
- if (!host.includes(":")) {
614
- return undefined;
615
- }
616
433
 
617
- if (host === "::") {
618
- return new Array(8).fill(0);
619
- }
620
434
 
621
- const [leftRaw, rightRaw, extra] = host.split("::");
622
435
 
623
- if (extra !== undefined) {
624
- return undefined;
625
- }
626
436
 
627
- const left = parseIpv6SegmentSide(leftRaw ?? "");
628
- const right = parseIpv6SegmentSide(rightRaw ?? "");
629
437
 
630
- if (!left || !right) {
631
- return undefined;
632
- }
633
438
 
634
- if (rightRaw === undefined) {
635
- return left.length === 8 ? left : undefined;
636
- }
637
-
638
- const missing = 8 - left.length - right.length;
639
-
640
- if (missing < 1) {
641
- return undefined;
642
- }
643
-
644
- return [...left, ...new Array(missing).fill(0), ...right];
645
- }
646
439
 
647
- function parseIpv6SegmentSide(value: string): number[] | undefined {
648
- if (!value) {
649
- return [];
650
- }
651
440
 
652
- const segments: number[] = [];
653
441
 
654
- const parts = value.split(":");
655
-
656
- for (const [index, part] of parts.entries()) {
657
- if (part.includes(".")) {
658
- if (index !== parts.length - 1) {
659
- return undefined;
660
- }
661
-
662
- const octets = parseIpv4Octets(part);
663
-
664
- if (!octets) {
665
- return undefined;
666
- }
667
-
668
- segments.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
669
- continue;
670
- }
671
442
 
672
- if (!/^[0-9a-f]{1,4}$/i.test(part)) {
673
- return undefined;
674
- }
675
-
676
- segments.push(parseInt(part, 16));
677
- }
678
-
679
- return segments;
680
- }
681
-
682
- function isBlockedIpv6Segments(parts: number[]): boolean {
683
- if (parts.length !== 8) {
684
- return true;
685
- }
686
-
687
- const embeddedIpv4 = ipv4OctetsFromIpv6Segments(parts);
688
-
689
- if (embeddedIpv4) {
690
- return isBlockedIpv4Octets(embeddedIpv4);
691
- }
692
-
693
- const isAllZero = parts.every((part) => part === 0);
694
-
695
- if (isAllZero || (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1)) {
696
- return true;
697
- }
698
-
699
- const first = parts[0];
700
-
701
- return (
702
- (first & 0xfe00) === 0xfc00 ||
703
- (first & 0xffc0) === 0xfe80 ||
704
- (first & 0xff00) === 0xff00
705
- );
706
- }
707
-
708
- function ipv4OctetsFromIpv6Segments(parts: number[]): number[] | undefined {
709
- const trailingIpv4 = [
710
- (parts[6] >> 8) & 0xff,
711
- parts[6] & 0xff,
712
- (parts[7] >> 8) & 0xff,
713
- parts[7] & 0xff,
714
- ];
715
-
716
- if (parts.slice(0, 5).every((part) => part === 0) && (parts[5] === 0xffff || parts[5] === 0)) {
717
- return trailingIpv4;
718
- }
719
-
720
- if (parts[0] === 0x64 && parts[1] === 0xff9b && parts.slice(2, 6).every((part) => part === 0)) {
721
- return trailingIpv4;
722
- }
723
-
724
- if (parts[0] === 0x2002) {
725
- return [
726
- (parts[1] >> 8) & 0xff,
727
- parts[1] & 0xff,
728
- (parts[2] >> 8) & 0xff,
729
- parts[2] & 0xff,
730
- ];
731
- }
732
-
733
- return undefined;
734
- }
735
443
 
736
444
  function redactedGenericWebhookOutputRequest(request: GenericWebhookOutputRequest): Record<string, unknown> {
737
445
  return {