Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
277 lines
7.9 KiB
JavaScript
277 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const http = require('node:http');
|
|
const https = require('node:https');
|
|
const { promises: dnsPromises } = require('node:dns');
|
|
const { URL } = require('node:url');
|
|
const { isIP } = require('node:net');
|
|
|
|
const BRAVE_SEARCH_URL = 'https://api.search.brave.com/res/v1/web/search';
|
|
const USER_AGENT = 'netcatty-ai-automation/1.0';
|
|
const MAX_FETCH_BYTES = 200_000;
|
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
const SEARCH_TIMEOUT_MS = 12_000;
|
|
|
|
function readApiKey() {
|
|
const file = String(process.env.BRAVE_API_KEY_FILE || '').trim();
|
|
if (file) {
|
|
const value = fs.readFileSync(file, 'utf8').replace(/[\r\n]+$/g, '');
|
|
if (!value) throw new Error('BRAVE_API_KEY_FILE is empty.');
|
|
return value;
|
|
}
|
|
const env = String(process.env.BRAVE_API_KEY || '').trim();
|
|
if (!env) throw new Error('BRAVE_API_KEY is not configured.');
|
|
return env;
|
|
}
|
|
|
|
function appendToolLog(event) {
|
|
const logPath = String(process.env.BRAVE_TOOL_LOG || '').trim();
|
|
if (!logPath) return;
|
|
fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`);
|
|
}
|
|
|
|
function requestJson(url, { headers = {}, timeoutMs = SEARCH_TIMEOUT_MS } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const parsed = new URL(url);
|
|
const lib = parsed.protocol === 'https:' ? https : http;
|
|
const req = lib.request(parsed, {
|
|
method: 'GET',
|
|
headers,
|
|
timeout: timeoutMs,
|
|
}, (res) => {
|
|
const chunks = [];
|
|
let size = 0;
|
|
res.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_FETCH_BYTES) {
|
|
req.destroy(new Error('Response exceeded byte limit.'));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 300)}`));
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch {
|
|
reject(new Error('Brave Search returned non-JSON.'));
|
|
}
|
|
});
|
|
});
|
|
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function requestText(url, { timeoutMs = FETCH_TIMEOUT_MS, redirects = 0 } = {}) {
|
|
if (redirects > 2) {
|
|
throw new Error('Too many redirects.');
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error('Invalid URL.');
|
|
}
|
|
if (parsed.protocol !== 'https:') {
|
|
throw new Error('Only https URLs can be fetched.');
|
|
}
|
|
if (isPrivateHostname(parsed.hostname)) {
|
|
throw new Error('Refusing to fetch a private or local address.');
|
|
}
|
|
let addresses;
|
|
try {
|
|
addresses = await dnsPromises.lookup(parsed.hostname, { all: true, verbatim: true });
|
|
} catch {
|
|
throw new Error('Could not resolve fetch host.');
|
|
}
|
|
if (
|
|
!addresses.length
|
|
|| addresses.some((entry) => isPrivateHostname(entry.address))
|
|
) {
|
|
throw new Error('Refusing to fetch a private or local address.');
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.request(parsed, {
|
|
method: 'GET',
|
|
headers: {
|
|
'User-Agent': USER_AGENT,
|
|
Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9',
|
|
},
|
|
timeout: timeoutMs,
|
|
}, (res) => {
|
|
const location = res.headers.location;
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
|
|
const next = new URL(location, parsed).toString();
|
|
resolve(requestText(next, { timeoutMs, redirects: redirects + 1 }));
|
|
res.resume();
|
|
return;
|
|
}
|
|
const chunks = [];
|
|
let size = 0;
|
|
res.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_FETCH_BYTES) {
|
|
req.destroy(new Error('Response exceeded byte limit.'));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
resolve(body);
|
|
});
|
|
});
|
|
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function isPrivateHostname(hostname) {
|
|
let host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
if (host.startsWith('::ffff:')) host = host.slice(7);
|
|
if (
|
|
host === 'localhost'
|
|
|| host.endsWith('.localhost')
|
|
|| host.endsWith('.local')
|
|
|| host === '0.0.0.0'
|
|
|| host === '::1'
|
|
) {
|
|
return true;
|
|
}
|
|
const ipVersion = isIP(host);
|
|
if (!ipVersion) return false;
|
|
if (ipVersion === 4) {
|
|
const [a, b] = host.split('.').map((part) => Number(part));
|
|
return (
|
|
a === 10
|
|
|| a === 127
|
|
|| a === 0
|
|
|| (a === 169 && b === 254)
|
|
|| (a === 172 && b >= 16 && b <= 31)
|
|
|| (a === 192 && b === 168)
|
|
);
|
|
}
|
|
return (
|
|
host === '::1'
|
|
|| host.startsWith('fc')
|
|
|| host.startsWith('fd')
|
|
|| host.startsWith('fe80')
|
|
);
|
|
}
|
|
|
|
function extractUrlsFromSearch(data) {
|
|
const urls = [];
|
|
const results = [];
|
|
const web = Array.isArray(data?.web?.results) ? data.web.results : [];
|
|
for (const item of web.slice(0, 8)) {
|
|
const url = String(item?.url || '').trim();
|
|
if (!url.startsWith('https://')) continue;
|
|
urls.push(url);
|
|
results.push({
|
|
title: String(item.title || '').slice(0, 200),
|
|
url,
|
|
description: String(item.description || '').slice(0, 500),
|
|
});
|
|
}
|
|
return { urls, results };
|
|
}
|
|
|
|
function stripHtml(value) {
|
|
return String(value || '')
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 8_000);
|
|
}
|
|
|
|
async function runSearch(query) {
|
|
const q = String(query || '').trim();
|
|
if (!q) throw new Error('Search query is required.');
|
|
if (q.length > 400) throw new Error('Search query is too long.');
|
|
const url = new URL(BRAVE_SEARCH_URL);
|
|
url.searchParams.set('q', q);
|
|
url.searchParams.set('count', '8');
|
|
url.searchParams.set('text_decorations', 'false');
|
|
url.searchParams.set('search_lang', 'en');
|
|
const data = await requestJson(url.toString(), {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Accept-Encoding': 'identity',
|
|
'X-Subscription-Token': readApiKey(),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
const extracted = extractUrlsFromSearch(data);
|
|
const event = {
|
|
ok: true,
|
|
action: 'search',
|
|
query: q,
|
|
urls: extracted.urls,
|
|
results: extracted.results,
|
|
};
|
|
appendToolLog(event);
|
|
return event;
|
|
}
|
|
|
|
async function runFetch(target) {
|
|
const url = String(target || '').trim();
|
|
if (!url.startsWith('https://')) throw new Error('Fetch URL must be https.');
|
|
const body = stripHtml(await requestText(url));
|
|
const event = {
|
|
ok: true,
|
|
action: 'fetch',
|
|
query: url,
|
|
urls: [url],
|
|
results: [{ title: url, url, description: body }],
|
|
};
|
|
appendToolLog(event);
|
|
return event;
|
|
}
|
|
|
|
async function main(argv) {
|
|
const action = String(argv[2] || '').toLowerCase();
|
|
const input = argv.slice(3).join(' ').trim();
|
|
if (action !== 'search' && action !== 'fetch') {
|
|
throw new Error('Usage: ai-brave-search.cjs <search|fetch> <query-or-url>');
|
|
}
|
|
const event = action === 'search' ? await runSearch(input) : await runFetch(input);
|
|
process.stdout.write(`${JSON.stringify(event, null, 2)}\n`);
|
|
}
|
|
|
|
module.exports = {
|
|
extractUrlsFromSearch,
|
|
isPrivateHostname,
|
|
stripHtml,
|
|
runSearch,
|
|
runFetch,
|
|
};
|
|
|
|
if (require.main === module) {
|
|
main(process.argv).catch((error) => {
|
|
const failed = {
|
|
ok: false,
|
|
action: String(process.argv[2] || ''),
|
|
query: process.argv.slice(3).join(' '),
|
|
error: error.message,
|
|
};
|
|
try { appendToolLog(failed); } catch { /* ignore log failure */ }
|
|
process.stderr.write(`${error.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|