@dexto/tools-builtins 1.16.6 → 1.17.0

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.
@@ -21,8 +21,11 @@ __export(delegate_to_url_tool_exports, {
21
21
  createDelegateToUrlTool: () => createDelegateToUrlTool
22
22
  });
23
23
  module.exports = __toCommonJS(delegate_to_url_tool_exports);
24
+ var import_node_util = require("node:util");
24
25
  var import_zod = require("zod");
25
26
  var import_errors = require("@dexto/core/errors");
27
+ var import_core = require("@dexto/core");
28
+ var import_undici = require("undici");
26
29
  var import_tools = require("@dexto/core/tools");
27
30
  const DelegateToUrlInputSchema = import_zod.z.object({
28
31
  url: import_zod.z.string().url().describe(
@@ -36,14 +39,58 @@ const DelegateToUrlInputSchema = import_zod.z.object({
36
39
  ),
37
40
  timeout: import_zod.z.number().optional().default(3e4).describe("Request timeout in milliseconds (default: 30000)")
38
41
  }).strict();
42
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
43
+ function assertDelegationUrl(rawUrl) {
44
+ const invalid = (reason) => new import_errors.DextoRuntimeError(
45
+ "DELEGATION_INVALID_URL",
46
+ import_errors.ErrorScope.TOOLS,
47
+ import_errors.ErrorType.USER,
48
+ `Invalid agent URL: ${reason}`
49
+ );
50
+ const parsed = new URL(rawUrl);
51
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
52
+ throw invalid("only http and https are supported");
53
+ }
54
+ if (parsed.username || parsed.password) {
55
+ throw invalid("credentials in the URL are not allowed");
56
+ }
57
+ if (rawUrl.includes("?") || rawUrl.includes("#")) {
58
+ throw invalid("the URL must not contain a query or fragment");
59
+ }
60
+ if ((0, import_core.isMetadataHost)(parsed.hostname)) {
61
+ throw invalid("link-local and metadata addresses are not allowed");
62
+ }
63
+ }
64
+ async function readJsonWithLimit(response) {
65
+ const reader = response.body?.getReader();
66
+ if (!reader) {
67
+ return JSON.parse(await response.text());
68
+ }
69
+ const decoder = new import_node_util.TextDecoder();
70
+ let total = 0;
71
+ let text = "";
72
+ while (true) {
73
+ const { done, value } = await reader.read();
74
+ if (done) break;
75
+ total += value.length;
76
+ if (total > MAX_RESPONSE_BYTES) {
77
+ await reader.cancel();
78
+ throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES} byte limit`);
79
+ }
80
+ text += decoder.decode(value, { stream: true });
81
+ }
82
+ return JSON.parse(text + decoder.decode());
83
+ }
39
84
  function isPlainObject(value) {
40
85
  return typeof value === "object" && value !== null && !Array.isArray(value);
41
86
  }
42
87
  class SimpleA2AClient {
43
88
  url;
44
89
  timeout;
45
- constructor(url, timeout = 3e4) {
90
+ dispatcher;
91
+ constructor(url, dispatcher, timeout = 3e4) {
46
92
  this.url = url.replace(/\/$/, "");
93
+ this.dispatcher = dispatcher;
47
94
  this.timeout = timeout;
48
95
  }
49
96
  async sendMessage(message, sessionId) {
@@ -79,22 +126,25 @@ class SimpleA2AClient {
79
126
  const controller = new AbortController();
80
127
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
81
128
  try {
82
- const response = await fetch(endpoint, {
129
+ const requestInit = {
83
130
  method: "POST",
84
131
  headers: {
85
132
  "Content-Type": "application/json",
86
133
  "User-Agent": "@dexto/core"
87
134
  },
88
135
  body: JSON.stringify(rpcRequest),
89
- signal: controller.signal
90
- });
136
+ redirect: "error",
137
+ signal: controller.signal,
138
+ dispatcher: this.dispatcher
139
+ };
140
+ const response = await fetch(endpoint, requestInit);
91
141
  if (!response.ok) {
92
142
  lastError = new Error(
93
143
  `HTTP ${response.status}: ${response.statusText} (tried ${endpoint})`
94
144
  );
95
145
  continue;
96
146
  }
97
- const data = await response.json();
147
+ const data = await readJsonWithLimit(response);
98
148
  if (isPlainObject(data) && "error" in data && data.error) {
99
149
  const errorMessage = isPlainObject(data.error) && typeof data.error.message === "string" ? data.error.message : "Unknown error";
100
150
  throw new Error(`Agent returned error: ${errorMessage}`);
@@ -145,7 +195,10 @@ class SimpleA2AClient {
145
195
  return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
146
196
  }
147
197
  }
148
- function createDelegateToUrlTool() {
198
+ function createDelegateToUrlTool(options = {}) {
199
+ const dispatcher = new import_undici.Agent({
200
+ connect: { lookup: (0, import_core.createMetadataCheckedLookup)(options.resolveHost) }
201
+ });
149
202
  return (0, import_tools.defineTool)({
150
203
  id: "delegate_to_url",
151
204
  description: 'Delegate a task to another A2A-compliant agent at a specific URL. Supports STATEFUL multi-turn conversations via sessionId parameter. USAGE: (1) First delegation: provide url + message. Tool returns a response AND a sessionId. (2) Follow-up: use the SAME sessionId to continue the conversation with that agent. The agent remembers previous context. EXAMPLE: First call {url: "http://agent:3001", message: "Analyze data X"} returns {sessionId: "xyz", response: "..."}. Second call {url: "http://agent:3001", message: "What was the top insight?", sessionId: "xyz"}. The agent will remember the first analysis and can answer specifically.',
@@ -159,8 +212,9 @@ function createDelegateToUrlTool() {
159
212
  },
160
213
  async execute(input, _context) {
161
214
  const { url, message, sessionId, timeout } = input;
215
+ assertDelegationUrl(url);
162
216
  try {
163
- const client = new SimpleA2AClient(url, timeout);
217
+ const client = new SimpleA2AClient(url, dispatcher, timeout);
164
218
  const effectiveSessionId = sessionId || `delegation-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
165
219
  const response = await client.sendMessage(message, effectiveSessionId);
166
220
  return {
@@ -6,11 +6,15 @@ declare const DelegateToUrlInputSchema: z.ZodObject<{
6
6
  sessionId: z.ZodOptional<z.ZodString>;
7
7
  timeout: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
8
8
  }, z.core.$strict>;
9
+ /** Resolves a host name to all of its addresses. Defaults to a DNS lookup; tests inject one. */
10
+ type HostResolver = (hostname: string) => Promise<string[]>;
9
11
  /**
10
12
  * Create the `delegate_to_url` tool.
11
13
  *
12
14
  * Delegates a message/task to another A2A-compliant agent URL via JSON-RPC and returns its response.
13
15
  */
14
- export declare function createDelegateToUrlTool(): Tool<typeof DelegateToUrlInputSchema>;
16
+ export declare function createDelegateToUrlTool(options?: {
17
+ resolveHost?: HostResolver;
18
+ }): Tool<typeof DelegateToUrlInputSchema>;
15
19
  export {};
16
20
  //# sourceMappingURL=delegate-to-url-tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"delegate-to-url-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/delegate-to-url-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AASpE,QAAA,MAAM,wBAAwB;;;;;kBA0BjB,CAAC;AAsJd;;;;GAIG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAAC,OAAO,wBAAwB,CAAC,CAgD/E"}
1
+ {"version":3,"file":"delegate-to-url-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/delegate-to-url-tool.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AAWpE,QAAA,MAAM,wBAAwB;;;;;kBA0BjB,CAAC;AAed,gGAAgG;AAChG,KAAK,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;AAmM5D;;;;GAIG;AACH,wBAAgB,uBAAuB,CACnC,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,YAAY,CAAA;CAAO,GAC7C,IAAI,CAAC,OAAO,wBAAwB,CAAC,CAuDvC"}
@@ -1,5 +1,8 @@
1
+ import { TextDecoder } from "node:util";
1
2
  import { z } from "zod";
2
3
  import { DextoRuntimeError, ErrorScope, ErrorType } from "@dexto/core/errors";
4
+ import { createMetadataCheckedLookup, isMetadataHost } from "@dexto/core";
5
+ import { Agent } from "undici";
3
6
  import {
4
7
  TOOL_ACTIVITY,
5
8
  createLocalToolCallHeader,
@@ -18,14 +21,58 @@ const DelegateToUrlInputSchema = z.object({
18
21
  ),
19
22
  timeout: z.number().optional().default(3e4).describe("Request timeout in milliseconds (default: 30000)")
20
23
  }).strict();
24
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
25
+ function assertDelegationUrl(rawUrl) {
26
+ const invalid = (reason) => new DextoRuntimeError(
27
+ "DELEGATION_INVALID_URL",
28
+ ErrorScope.TOOLS,
29
+ ErrorType.USER,
30
+ `Invalid agent URL: ${reason}`
31
+ );
32
+ const parsed = new URL(rawUrl);
33
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
34
+ throw invalid("only http and https are supported");
35
+ }
36
+ if (parsed.username || parsed.password) {
37
+ throw invalid("credentials in the URL are not allowed");
38
+ }
39
+ if (rawUrl.includes("?") || rawUrl.includes("#")) {
40
+ throw invalid("the URL must not contain a query or fragment");
41
+ }
42
+ if (isMetadataHost(parsed.hostname)) {
43
+ throw invalid("link-local and metadata addresses are not allowed");
44
+ }
45
+ }
46
+ async function readJsonWithLimit(response) {
47
+ const reader = response.body?.getReader();
48
+ if (!reader) {
49
+ return JSON.parse(await response.text());
50
+ }
51
+ const decoder = new TextDecoder();
52
+ let total = 0;
53
+ let text = "";
54
+ while (true) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ total += value.length;
58
+ if (total > MAX_RESPONSE_BYTES) {
59
+ await reader.cancel();
60
+ throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES} byte limit`);
61
+ }
62
+ text += decoder.decode(value, { stream: true });
63
+ }
64
+ return JSON.parse(text + decoder.decode());
65
+ }
21
66
  function isPlainObject(value) {
22
67
  return typeof value === "object" && value !== null && !Array.isArray(value);
23
68
  }
24
69
  class SimpleA2AClient {
25
70
  url;
26
71
  timeout;
27
- constructor(url, timeout = 3e4) {
72
+ dispatcher;
73
+ constructor(url, dispatcher, timeout = 3e4) {
28
74
  this.url = url.replace(/\/$/, "");
75
+ this.dispatcher = dispatcher;
29
76
  this.timeout = timeout;
30
77
  }
31
78
  async sendMessage(message, sessionId) {
@@ -61,22 +108,25 @@ class SimpleA2AClient {
61
108
  const controller = new AbortController();
62
109
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
63
110
  try {
64
- const response = await fetch(endpoint, {
111
+ const requestInit = {
65
112
  method: "POST",
66
113
  headers: {
67
114
  "Content-Type": "application/json",
68
115
  "User-Agent": "@dexto/core"
69
116
  },
70
117
  body: JSON.stringify(rpcRequest),
71
- signal: controller.signal
72
- });
118
+ redirect: "error",
119
+ signal: controller.signal,
120
+ dispatcher: this.dispatcher
121
+ };
122
+ const response = await fetch(endpoint, requestInit);
73
123
  if (!response.ok) {
74
124
  lastError = new Error(
75
125
  `HTTP ${response.status}: ${response.statusText} (tried ${endpoint})`
76
126
  );
77
127
  continue;
78
128
  }
79
- const data = await response.json();
129
+ const data = await readJsonWithLimit(response);
80
130
  if (isPlainObject(data) && "error" in data && data.error) {
81
131
  const errorMessage = isPlainObject(data.error) && typeof data.error.message === "string" ? data.error.message : "Unknown error";
82
132
  throw new Error(`Agent returned error: ${errorMessage}`);
@@ -127,7 +177,10 @@ class SimpleA2AClient {
127
177
  return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
128
178
  }
129
179
  }
130
- function createDelegateToUrlTool() {
180
+ function createDelegateToUrlTool(options = {}) {
181
+ const dispatcher = new Agent({
182
+ connect: { lookup: createMetadataCheckedLookup(options.resolveHost) }
183
+ });
131
184
  return defineTool({
132
185
  id: "delegate_to_url",
133
186
  description: 'Delegate a task to another A2A-compliant agent at a specific URL. Supports STATEFUL multi-turn conversations via sessionId parameter. USAGE: (1) First delegation: provide url + message. Tool returns a response AND a sessionId. (2) Follow-up: use the SAME sessionId to continue the conversation with that agent. The agent remembers previous context. EXAMPLE: First call {url: "http://agent:3001", message: "Analyze data X"} returns {sessionId: "xyz", response: "..."}. Second call {url: "http://agent:3001", message: "What was the top insight?", sessionId: "xyz"}. The agent will remember the first analysis and can answer specifically.',
@@ -141,8 +194,9 @@ function createDelegateToUrlTool() {
141
194
  },
142
195
  async execute(input, _context) {
143
196
  const { url, message, sessionId, timeout } = input;
197
+ assertDelegationUrl(url);
144
198
  try {
145
- const client = new SimpleA2AClient(url, timeout);
199
+ const client = new SimpleA2AClient(url, dispatcher, timeout);
146
200
  const effectiveSessionId = sessionId || `delegation-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
147
201
  const response = await client.sendMessage(message, effectiveSessionId);
148
202
  return {
@@ -1,4 +1,5 @@
1
1
  "use strict";
2
+ var import_node_http = require("node:http");
2
3
  var import_vitest = require("vitest");
3
4
  var import_delegate_to_url_tool = require("./delegate-to-url-tool.js");
4
5
  var import_core = require("@dexto/core");
@@ -19,6 +20,7 @@ function createMockLogger() {
19
20
  };
20
21
  return logger;
21
22
  }
23
+ const publicHost = async () => ["93.184.216.34"];
22
24
  function createToolContext(logger) {
23
25
  return { logger };
24
26
  }
@@ -33,7 +35,7 @@ function createToolContext(logger) {
33
35
  throw new Error("boom");
34
36
  });
35
37
  import_vitest.vi.stubGlobal("fetch", fetchMock);
36
- const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)();
38
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({ resolveHost: publicHost });
37
39
  const input = tool.inputSchema.parse({
38
40
  url: "http://example.com",
39
41
  message: "hi",
@@ -55,7 +57,7 @@ function createToolContext(logger) {
55
57
  throw abortError;
56
58
  });
57
59
  import_vitest.vi.stubGlobal("fetch", fetchMock);
58
- const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)();
60
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({ resolveHost: publicHost });
59
61
  const input = tool.inputSchema.parse({
60
62
  url: "http://example.com",
61
63
  message: "hi",
@@ -74,3 +76,97 @@ function createToolContext(logger) {
74
76
  }
75
77
  });
76
78
  });
79
+ (0, import_vitest.describe)("delegate_to_url URL checks", () => {
80
+ (0, import_vitest.afterEach)(() => {
81
+ import_vitest.vi.unstubAllGlobals();
82
+ });
83
+ import_vitest.it.each([
84
+ "ftp://example.com",
85
+ "http://user:pass@example.com",
86
+ "http://127.0.0.1:2375/containers/create?",
87
+ "http://example.com/#fragment",
88
+ "http://169.254.169.254",
89
+ "http://[::ffff:169.254.169.254]",
90
+ "http://[::ffff:a9fe:a9fe]",
91
+ "http://169.254.169.254.",
92
+ "http://metadata.google.internal.",
93
+ "http://[fe80::1]",
94
+ "http://[fd00:ec2::254]",
95
+ "http://100.100.100.200",
96
+ "http://192.0.0.192",
97
+ "http://168.63.129.16",
98
+ "http://[fd20:ce::254]",
99
+ "http://2852039166"
100
+ ])("rejects %s without sending a request", async (url) => {
101
+ const fetchMock = import_vitest.vi.fn();
102
+ import_vitest.vi.stubGlobal("fetch", fetchMock);
103
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({ resolveHost: publicHost });
104
+ const input = tool.inputSchema.parse({ url, message: "hi" });
105
+ await (0, import_vitest.expect)(
106
+ tool.execute(input, createToolContext(createMockLogger()))
107
+ ).rejects.toMatchObject({
108
+ code: "DELEGATION_INVALID_URL"
109
+ });
110
+ (0, import_vitest.expect)(fetchMock).not.toHaveBeenCalled();
111
+ });
112
+ (0, import_vitest.it)("still delegates to a local agent and refuses redirects", async () => {
113
+ const fetchMock = import_vitest.vi.fn(
114
+ async (_url, _init) => new Response(JSON.stringify({ result: "ok" }), {
115
+ headers: { "content-type": "application/json" }
116
+ })
117
+ );
118
+ import_vitest.vi.stubGlobal("fetch", fetchMock);
119
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({ resolveHost: publicHost });
120
+ const input = tool.inputSchema.parse({ url: "http://localhost:3001", message: "hi" });
121
+ await tool.execute(input, createToolContext(createMockLogger()));
122
+ (0, import_vitest.expect)(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3001/v1/jsonrpc");
123
+ (0, import_vitest.expect)(fetchMock.mock.calls[0]?.[1]?.redirect).toBe("error");
124
+ });
125
+ });
126
+ (0, import_vitest.describe)("delegate_to_url connects through the checked address", () => {
127
+ let server;
128
+ let port;
129
+ let requests;
130
+ (0, import_vitest.beforeEach)(async () => {
131
+ requests = 0;
132
+ server = (0, import_node_http.createServer)((_request, response) => {
133
+ requests += 1;
134
+ response.setHeader("content-type", "application/json");
135
+ response.end(JSON.stringify({ result: "ok" }));
136
+ });
137
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
138
+ port = server.address().port;
139
+ });
140
+ (0, import_vitest.afterEach)(async () => {
141
+ await new Promise((resolve) => server.close(() => resolve()));
142
+ });
143
+ (0, import_vitest.it)("uses the answer it checked even if the name answers differently afterwards", async () => {
144
+ let lookups = 0;
145
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({
146
+ resolveHost: async () => lookups++ === 0 ? ["127.0.0.1"] : ["169.254.169.254"]
147
+ });
148
+ const input = tool.inputSchema.parse({
149
+ url: `http://rebind.example:${port}`,
150
+ message: "hi"
151
+ });
152
+ const result = await tool.execute(input, createToolContext(createMockLogger()));
153
+ (0, import_vitest.expect)(result).toMatchObject({ success: true });
154
+ (0, import_vitest.expect)(requests).toBe(1);
155
+ });
156
+ (0, import_vitest.it)("never connects when any answer for the name is a metadata address", async () => {
157
+ const tool = (0, import_delegate_to_url_tool.createDelegateToUrlTool)({
158
+ resolveHost: async () => ["127.0.0.1", "::ffff:a9fe:a9fe"]
159
+ });
160
+ const input = tool.inputSchema.parse({
161
+ // `localhost` really resolves to this machine, so without the pinned lookup this request
162
+ // would reach the server.
163
+ url: `http://localhost:${port}`,
164
+ message: "hi",
165
+ timeout: 5e3
166
+ });
167
+ await (0, import_vitest.expect)(
168
+ tool.execute(input, createToolContext(createMockLogger()))
169
+ ).rejects.toMatchObject({ code: "DELEGATION_FAILED" });
170
+ (0, import_vitest.expect)(requests).toBe(0);
171
+ });
172
+ });
@@ -1,4 +1,5 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { createServer } from "node:http";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import { createDelegateToUrlTool } from "./delegate-to-url-tool.js";
3
4
  import { DextoRuntimeError, ErrorScope, ErrorType } from "@dexto/core";
4
5
  function createMockLogger() {
@@ -18,6 +19,7 @@ function createMockLogger() {
18
19
  };
19
20
  return logger;
20
21
  }
22
+ const publicHost = async () => ["93.184.216.34"];
21
23
  function createToolContext(logger) {
22
24
  return { logger };
23
25
  }
@@ -32,7 +34,7 @@ describe("delegate_to_url tool", () => {
32
34
  throw new Error("boom");
33
35
  });
34
36
  vi.stubGlobal("fetch", fetchMock);
35
- const tool = createDelegateToUrlTool();
37
+ const tool = createDelegateToUrlTool({ resolveHost: publicHost });
36
38
  const input = tool.inputSchema.parse({
37
39
  url: "http://example.com",
38
40
  message: "hi",
@@ -54,7 +56,7 @@ describe("delegate_to_url tool", () => {
54
56
  throw abortError;
55
57
  });
56
58
  vi.stubGlobal("fetch", fetchMock);
57
- const tool = createDelegateToUrlTool();
59
+ const tool = createDelegateToUrlTool({ resolveHost: publicHost });
58
60
  const input = tool.inputSchema.parse({
59
61
  url: "http://example.com",
60
62
  message: "hi",
@@ -73,3 +75,97 @@ describe("delegate_to_url tool", () => {
73
75
  }
74
76
  });
75
77
  });
78
+ describe("delegate_to_url URL checks", () => {
79
+ afterEach(() => {
80
+ vi.unstubAllGlobals();
81
+ });
82
+ it.each([
83
+ "ftp://example.com",
84
+ "http://user:pass@example.com",
85
+ "http://127.0.0.1:2375/containers/create?",
86
+ "http://example.com/#fragment",
87
+ "http://169.254.169.254",
88
+ "http://[::ffff:169.254.169.254]",
89
+ "http://[::ffff:a9fe:a9fe]",
90
+ "http://169.254.169.254.",
91
+ "http://metadata.google.internal.",
92
+ "http://[fe80::1]",
93
+ "http://[fd00:ec2::254]",
94
+ "http://100.100.100.200",
95
+ "http://192.0.0.192",
96
+ "http://168.63.129.16",
97
+ "http://[fd20:ce::254]",
98
+ "http://2852039166"
99
+ ])("rejects %s without sending a request", async (url) => {
100
+ const fetchMock = vi.fn();
101
+ vi.stubGlobal("fetch", fetchMock);
102
+ const tool = createDelegateToUrlTool({ resolveHost: publicHost });
103
+ const input = tool.inputSchema.parse({ url, message: "hi" });
104
+ await expect(
105
+ tool.execute(input, createToolContext(createMockLogger()))
106
+ ).rejects.toMatchObject({
107
+ code: "DELEGATION_INVALID_URL"
108
+ });
109
+ expect(fetchMock).not.toHaveBeenCalled();
110
+ });
111
+ it("still delegates to a local agent and refuses redirects", async () => {
112
+ const fetchMock = vi.fn(
113
+ async (_url, _init) => new Response(JSON.stringify({ result: "ok" }), {
114
+ headers: { "content-type": "application/json" }
115
+ })
116
+ );
117
+ vi.stubGlobal("fetch", fetchMock);
118
+ const tool = createDelegateToUrlTool({ resolveHost: publicHost });
119
+ const input = tool.inputSchema.parse({ url: "http://localhost:3001", message: "hi" });
120
+ await tool.execute(input, createToolContext(createMockLogger()));
121
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3001/v1/jsonrpc");
122
+ expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBe("error");
123
+ });
124
+ });
125
+ describe("delegate_to_url connects through the checked address", () => {
126
+ let server;
127
+ let port;
128
+ let requests;
129
+ beforeEach(async () => {
130
+ requests = 0;
131
+ server = createServer((_request, response) => {
132
+ requests += 1;
133
+ response.setHeader("content-type", "application/json");
134
+ response.end(JSON.stringify({ result: "ok" }));
135
+ });
136
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
137
+ port = server.address().port;
138
+ });
139
+ afterEach(async () => {
140
+ await new Promise((resolve) => server.close(() => resolve()));
141
+ });
142
+ it("uses the answer it checked even if the name answers differently afterwards", async () => {
143
+ let lookups = 0;
144
+ const tool = createDelegateToUrlTool({
145
+ resolveHost: async () => lookups++ === 0 ? ["127.0.0.1"] : ["169.254.169.254"]
146
+ });
147
+ const input = tool.inputSchema.parse({
148
+ url: `http://rebind.example:${port}`,
149
+ message: "hi"
150
+ });
151
+ const result = await tool.execute(input, createToolContext(createMockLogger()));
152
+ expect(result).toMatchObject({ success: true });
153
+ expect(requests).toBe(1);
154
+ });
155
+ it("never connects when any answer for the name is a metadata address", async () => {
156
+ const tool = createDelegateToUrlTool({
157
+ resolveHost: async () => ["127.0.0.1", "::ffff:a9fe:a9fe"]
158
+ });
159
+ const input = tool.inputSchema.parse({
160
+ // `localhost` really resolves to this machine, so without the pinned lookup this request
161
+ // would reach the server.
162
+ url: `http://localhost:${port}`,
163
+ message: "hi",
164
+ timeout: 5e3
165
+ });
166
+ await expect(
167
+ tool.execute(input, createToolContext(createMockLogger()))
168
+ ).rejects.toMatchObject({ code: "DELEGATION_FAILED" });
169
+ expect(requests).toBe(0);
170
+ });
171
+ });