@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.
- package/dist/implementations/delegate-to-url-tool.cjs +61 -7
- package/dist/implementations/delegate-to-url-tool.d.ts +5 -1
- package/dist/implementations/delegate-to-url-tool.d.ts.map +1 -1
- package/dist/implementations/delegate-to-url-tool.js +61 -7
- package/dist/implementations/delegate-to-url-tool.test.cjs +98 -2
- package/dist/implementations/delegate-to-url-tool.test.js +99 -3
- package/dist/implementations/http-request-tool.cjs +32 -89
- package/dist/implementations/http-request-tool.d.ts.map +1 -1
- package/dist/implementations/http-request-tool.js +32 -89
- package/dist/implementations/http-request-tool.test.cjs +248 -1
- package/dist/implementations/http-request-tool.test.js +248 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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":"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
+
});
|