All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
226 lines
6.4 KiB
TypeScript
226 lines
6.4 KiB
TypeScript
import { Type } from '@sinclair/typebox';
|
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
const MAX_RESPONSE_BYTES = 512 * 1024; // 512 KB
|
|
|
|
/**
|
|
* Blocked URL patterns (private IP ranges, localhost, link-local).
|
|
*/
|
|
const BLOCKED_HOSTNAMES = [
|
|
/^localhost$/i,
|
|
/^127\./,
|
|
/^10\./,
|
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
/^192\.168\./,
|
|
/^::1$/,
|
|
/^fc[0-9a-f][0-9a-f]:/i,
|
|
/^fe80:/i,
|
|
/^0\.0\.0\.0$/,
|
|
/^169\.254\./,
|
|
];
|
|
|
|
function isBlockedUrl(urlString: string): string | null {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(urlString);
|
|
} catch {
|
|
return `Invalid URL: ${urlString}`;
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return `Unsupported protocol: ${parsed.protocol}. Only http and https are allowed.`;
|
|
}
|
|
const hostname = parsed.hostname;
|
|
for (const pattern of BLOCKED_HOSTNAMES) {
|
|
if (pattern.test(hostname)) {
|
|
return `Blocked: requests to "${hostname}" are not allowed (private/local addresses).`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function fetchWithLimit(
|
|
url: string,
|
|
options: RequestInit,
|
|
timeoutMs: number,
|
|
): Promise<{ text: string; status: number; contentType: string }> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
|
|
// Stream response and enforce size limit
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
return { text: '', status: response.status, contentType };
|
|
}
|
|
|
|
const chunks: Uint8Array[] = [];
|
|
let totalBytes = 0;
|
|
let truncated = false;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
totalBytes += value.length;
|
|
if (totalBytes > MAX_RESPONSE_BYTES) {
|
|
const remaining = MAX_RESPONSE_BYTES - (totalBytes - value.length);
|
|
chunks.push(value.subarray(0, remaining));
|
|
truncated = true;
|
|
reader.cancel();
|
|
break;
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
|
|
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
combined.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
|
|
let text = new TextDecoder().decode(combined);
|
|
if (truncated) {
|
|
text += '\n[response truncated at 512 KB limit]';
|
|
}
|
|
|
|
return { text, status: response.status, contentType };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export function createWebTools(): ToolDefinition[] {
|
|
const webGet: ToolDefinition = {
|
|
name: 'web_get',
|
|
label: 'HTTP GET',
|
|
description:
|
|
'Perform an HTTP GET request and return the response body. Private/local addresses are blocked.',
|
|
parameters: Type.Object({
|
|
url: Type.String({ description: 'URL to fetch (http/https only)' }),
|
|
headers: Type.Optional(
|
|
Type.Record(Type.String(), Type.String(), {
|
|
description: 'Optional request headers as key-value pairs',
|
|
}),
|
|
),
|
|
timeout: Type.Optional(
|
|
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { url, headers, timeout } = params as {
|
|
url: string;
|
|
headers?: Record<string, string>;
|
|
timeout?: number;
|
|
};
|
|
|
|
const blocked = isBlockedUrl(url);
|
|
if (blocked) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
|
details: undefined,
|
|
};
|
|
}
|
|
|
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
|
|
|
try {
|
|
const result = await fetchWithLimit(
|
|
url,
|
|
{ method: 'GET', headers: headers ?? {} },
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
|
},
|
|
],
|
|
details: undefined,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Error fetching URL: ${msg}` }],
|
|
details: undefined,
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
const webPost: ToolDefinition = {
|
|
name: 'web_post',
|
|
label: 'HTTP POST',
|
|
description:
|
|
'Perform an HTTP POST request with a JSON or text body. Private/local addresses are blocked.',
|
|
parameters: Type.Object({
|
|
url: Type.String({ description: 'URL to POST to (http/https only)' }),
|
|
body: Type.String({ description: 'Request body (JSON string or plain text)' }),
|
|
contentType: Type.Optional(
|
|
Type.String({ description: 'Content-Type header (default: application/json)' }),
|
|
),
|
|
headers: Type.Optional(
|
|
Type.Record(Type.String(), Type.String(), {
|
|
description: 'Optional additional request headers',
|
|
}),
|
|
),
|
|
timeout: Type.Optional(
|
|
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { url, body, contentType, headers, timeout } = params as {
|
|
url: string;
|
|
body: string;
|
|
contentType?: string;
|
|
headers?: Record<string, string>;
|
|
timeout?: number;
|
|
};
|
|
|
|
const blocked = isBlockedUrl(url);
|
|
if (blocked) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
|
details: undefined,
|
|
};
|
|
}
|
|
|
|
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
|
const ct = contentType ?? 'application/json';
|
|
|
|
try {
|
|
const result = await fetchWithLimit(
|
|
url,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': ct, ...(headers ?? {}) },
|
|
body,
|
|
},
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
|
},
|
|
],
|
|
details: undefined,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Error posting to URL: ${msg}` }],
|
|
details: undefined,
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
return [webGet, webPost];
|
|
}
|