@nanhara/hara 0.121.0 → 0.122.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/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/tools/web.js
CHANGED
|
@@ -1,40 +1,148 @@
|
|
|
1
1
|
// web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// redirect hop, and the body is read under a hard byte ceiling.
|
|
2
|
+
// NOT sandboxed (network egress is in-process, not via bash) — so it carries an SSRF guard: private/
|
|
3
|
+
// loopback/link-local targets are refused, the verified DNS address is pinned to the actual socket on
|
|
4
|
+
// every redirect hop (DNS-rebinding safe), and the body is read under a hard byte ceiling.
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { lookup } from "node:dns/promises";
|
|
7
7
|
import { isIP } from "node:net";
|
|
8
|
+
import { request as httpRequest } from "node:http";
|
|
9
|
+
import { request as httpsRequest } from "node:https";
|
|
8
10
|
import { wrapUntrusted } from "../security/external-content.js";
|
|
9
11
|
const MAX = 60_000;
|
|
12
|
+
const SEARCH_ATTEMPT_MS = 8_000;
|
|
13
|
+
const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
|
|
14
|
+
function ipv6Words(input) {
|
|
15
|
+
let value = input.toLowerCase();
|
|
16
|
+
const dotted = value.lastIndexOf(":") >= 0 ? value.slice(value.lastIndexOf(":") + 1) : "";
|
|
17
|
+
if (dotted.includes(".")) {
|
|
18
|
+
const octets = dotted.split(".").map(Number);
|
|
19
|
+
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
20
|
+
return null;
|
|
21
|
+
value = `${value.slice(0, value.lastIndexOf(":") + 1)}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
22
|
+
}
|
|
23
|
+
const halves = value.split("::");
|
|
24
|
+
if (halves.length > 2)
|
|
25
|
+
return null;
|
|
26
|
+
const parse = (part) => {
|
|
27
|
+
if (!part)
|
|
28
|
+
return [];
|
|
29
|
+
const pieces = part.split(":");
|
|
30
|
+
if (pieces.some((piece) => !/^[0-9a-f]{1,4}$/.test(piece)))
|
|
31
|
+
return null;
|
|
32
|
+
return pieces.map((piece) => Number.parseInt(piece, 16));
|
|
33
|
+
};
|
|
34
|
+
const left = parse(halves[0]);
|
|
35
|
+
const right = parse(halves[1] ?? "");
|
|
36
|
+
if (!left || !right)
|
|
37
|
+
return null;
|
|
38
|
+
if (halves.length === 1)
|
|
39
|
+
return left.length === 8 ? left : null;
|
|
40
|
+
const zeros = 8 - left.length - right.length;
|
|
41
|
+
return zeros > 0 ? [...left, ...Array(zeros).fill(0), ...right] : null;
|
|
42
|
+
}
|
|
10
43
|
/** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
|
|
11
44
|
export function isPrivateIp(ip) {
|
|
12
45
|
const host = ip.replace(/^\[|\]$/g, "");
|
|
13
46
|
if (isIP(host) === 4) {
|
|
14
47
|
const p = host.split(".").map(Number);
|
|
15
|
-
return p[0] === 0 || p[0] === 10 || p[0] === 127 ||
|
|
48
|
+
return (p[0] === 0 || p[0] === 10 || p[0] === 127 ||
|
|
49
|
+
(p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
|
|
50
|
+
(p[0] === 169 && p[1] === 254) ||
|
|
51
|
+
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
|
|
52
|
+
(p[0] === 192 && p[1] === 0 && p[2] === 0 && p[3] !== 9 && p[3] !== 10) ||
|
|
53
|
+
(p[0] === 192 && p[1] === 0 && p[2] === 2) ||
|
|
54
|
+
(p[0] === 192 && p[1] === 88 && p[2] === 99) ||
|
|
55
|
+
(p[0] === 192 && p[1] === 168) ||
|
|
56
|
+
(p[0] === 198 && (p[1] === 18 || p[1] === 19)) ||
|
|
57
|
+
(p[0] === 198 && p[1] === 51 && p[2] === 100) ||
|
|
58
|
+
(p[0] === 203 && p[1] === 0 && p[2] === 113) ||
|
|
59
|
+
p[0] >= 224 // multicast + reserved/broadcast space is not a public unicast web destination
|
|
60
|
+
);
|
|
16
61
|
}
|
|
17
62
|
const l = host.toLowerCase();
|
|
18
|
-
|
|
63
|
+
const words = ipv6Words(l);
|
|
64
|
+
if (!words)
|
|
65
|
+
return false;
|
|
66
|
+
if (words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1)
|
|
19
67
|
return true;
|
|
20
|
-
if (
|
|
21
|
-
return true; // link-local
|
|
22
|
-
|
|
23
|
-
|
|
68
|
+
if ((words[0] & 0xffc0) === 0xfe80)
|
|
69
|
+
return true; // fe80::/10 link-local (fe80..febf)
|
|
70
|
+
if ((words[0] & 0xfe00) === 0xfc00)
|
|
71
|
+
return true; // fc00::/7 unique-local
|
|
72
|
+
if ((words[0] & 0xffc0) === 0xfec0)
|
|
73
|
+
return true; // fec0::/10 deprecated site-local, still internal
|
|
74
|
+
// IPv4-mapped/compatible spellings are normalized by URL to hexadecimal (for example
|
|
75
|
+
// ::ffff:127.0.0.1 → ::ffff:7f00:1), so classify the embedded address from parsed words.
|
|
76
|
+
const mapped = words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
|
|
77
|
+
const compatible = words.slice(0, 6).every((word) => word === 0);
|
|
78
|
+
if (mapped || compatible) {
|
|
79
|
+
const v4 = `${words[6] >>> 8}.${words[6] & 0xff}.${words[7] >>> 8}.${words[7] & 0xff}`;
|
|
80
|
+
return isPrivateIp(v4);
|
|
81
|
+
}
|
|
82
|
+
// Native public IPv6 is global-unicast 2000::/3. Also reject special-use ranges that sit inside it:
|
|
83
|
+
// Teredo, benchmarking/ORCHID/documentation, and deprecated 6to4 transition addresses.
|
|
84
|
+
if ((words[0] & 0xe000) !== 0x2000)
|
|
85
|
+
return true;
|
|
86
|
+
if (words[0] === 0x2002)
|
|
87
|
+
return true; // 6to4 can tunnel an embedded non-public IPv4 target
|
|
88
|
+
if (words[0] === 0x2001) {
|
|
89
|
+
if (words[1] === 0x0000 || words[1] === 0x0002 || words[1] === 0x0db8)
|
|
90
|
+
return true;
|
|
91
|
+
if ((words[1] & 0xfff0) === 0x0010 || (words[1] & 0xfff0) === 0x0020)
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return words[0] === 0x3fff && (words[1] & 0xf000) === 0x0000; // 3fff::/20 documentation
|
|
24
95
|
}
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
|
|
96
|
+
/** Resolve once, reject the hostname if ANY answer is internal, then return the exact public address the
|
|
97
|
+
* socket must use. Rejecting mixed public/private answers prevents round-robin records from becoming a
|
|
98
|
+
* probabilistic bypass. The optional resolver keeps the policy directly testable. */
|
|
99
|
+
export async function resolvePublicHost(hostname, resolver = lookup) {
|
|
28
100
|
const host = hostname.replace(/^\[|\]$/g, "");
|
|
29
101
|
if (isIP(host)) {
|
|
30
102
|
if (isPrivateIp(host))
|
|
31
103
|
throw new Error(`refusing to fetch ${host} (private/loopback address)`);
|
|
32
|
-
return;
|
|
104
|
+
return { address: host, family: isIP(host) };
|
|
33
105
|
}
|
|
34
|
-
const addrs = await
|
|
35
|
-
|
|
106
|
+
const addrs = await resolver(host, { all: true });
|
|
107
|
+
if (!addrs.length)
|
|
108
|
+
throw new Error(`could not resolve ${host}`);
|
|
109
|
+
for (const a of addrs) {
|
|
110
|
+
const family = isIP(a.address);
|
|
111
|
+
if (!family || family !== a.family)
|
|
112
|
+
throw new Error(`resolver returned an invalid address for ${host}`);
|
|
36
113
|
if (isPrivateIp(a.address))
|
|
37
114
|
throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
|
|
115
|
+
}
|
|
116
|
+
const chosen = addrs[0];
|
|
117
|
+
return { address: chosen.address, family: chosen.family };
|
|
118
|
+
}
|
|
119
|
+
/** One HTTP hop whose TCP socket is pinned to the address approved above. `Host` and TLS SNI retain the
|
|
120
|
+
* original hostname, so virtual hosting/certificate checks work without a second DNS lookup. */
|
|
121
|
+
async function requestPinned(url, pinned, signal) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)({
|
|
124
|
+
protocol: url.protocol,
|
|
125
|
+
hostname: pinned.address,
|
|
126
|
+
family: pinned.family,
|
|
127
|
+
port: url.port || undefined,
|
|
128
|
+
path: `${url.pathname}${url.search}`,
|
|
129
|
+
method: "GET",
|
|
130
|
+
signal,
|
|
131
|
+
servername: url.hostname.replace(/^\[|\]$/g, ""),
|
|
132
|
+
headers: {
|
|
133
|
+
host: url.host,
|
|
134
|
+
"user-agent": "hara-cli",
|
|
135
|
+
accept: "text/html,text/plain,application/json,*/*",
|
|
136
|
+
},
|
|
137
|
+
}, (body) => {
|
|
138
|
+
const headers = new Headers();
|
|
139
|
+
for (let i = 0; i < body.rawHeaders.length; i += 2)
|
|
140
|
+
headers.append(body.rawHeaders[i], body.rawHeaders[i + 1]);
|
|
141
|
+
resolve({ status: body.statusCode ?? 0, headers, body });
|
|
142
|
+
});
|
|
143
|
+
request.once("error", reject);
|
|
144
|
+
request.end();
|
|
145
|
+
});
|
|
38
146
|
}
|
|
39
147
|
/** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
|
|
40
148
|
async function readCapped(res, maxBytes) {
|
|
@@ -63,6 +171,25 @@ async function readCapped(res, maxBytes) {
|
|
|
63
171
|
}
|
|
64
172
|
return Buffer.concat(chunks).toString("utf8");
|
|
65
173
|
}
|
|
174
|
+
async function readPinnedCapped(res, maxBytes) {
|
|
175
|
+
const chunks = [];
|
|
176
|
+
let total = 0;
|
|
177
|
+
for await (const value of res) {
|
|
178
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
179
|
+
const remaining = maxBytes - total;
|
|
180
|
+
if (remaining <= 0) {
|
|
181
|
+
res.destroy();
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk);
|
|
185
|
+
total += Math.min(chunk.length, remaining);
|
|
186
|
+
if (chunk.length >= remaining) {
|
|
187
|
+
res.destroy();
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
192
|
+
}
|
|
66
193
|
/** Strip HTML to a readable-ish plain-text approximation (no dependency). */
|
|
67
194
|
export function htmlToText(html) {
|
|
68
195
|
return html
|
|
@@ -115,6 +242,134 @@ export function parseSearchResults(html, limit) {
|
|
|
115
242
|
}
|
|
116
243
|
return out;
|
|
117
244
|
}
|
|
245
|
+
/** Parse Baidu's server-rendered result headings. Baidu result links are redirects, which is fine:
|
|
246
|
+
* web_fetch follows redirects while re-running its SSRF check on every hop. This gives mainland users a
|
|
247
|
+
* keyless search path that does not depend on an overseas API being reachable. */
|
|
248
|
+
export function parseBaiduSearchResults(html, limit) {
|
|
249
|
+
const strip = (s) => s
|
|
250
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
251
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
252
|
+
.replace(/<[^>]+>/g, " ")
|
|
253
|
+
.replace(/ | /gi, " ")
|
|
254
|
+
.replace(/&/gi, "&")
|
|
255
|
+
.replace(/</gi, "<")
|
|
256
|
+
.replace(/>/gi, ">")
|
|
257
|
+
.replace(/"/gi, '"')
|
|
258
|
+
.replace(/'|'/gi, "'")
|
|
259
|
+
.replace(/\s+/g, " ")
|
|
260
|
+
.trim();
|
|
261
|
+
const headings = [];
|
|
262
|
+
const re = /<h3\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h3>/gi;
|
|
263
|
+
let m;
|
|
264
|
+
while ((m = re.exec(html)) && headings.length < limit) {
|
|
265
|
+
const title = strip(m[3]);
|
|
266
|
+
const url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
267
|
+
if (title && /^https?:\/\//i.test(url))
|
|
268
|
+
headings.push({ at: m.index, end: re.lastIndex, title, url });
|
|
269
|
+
}
|
|
270
|
+
return headings.map((h, i) => {
|
|
271
|
+
// The abstract normally sits between this heading and the next result heading. Strip that bounded
|
|
272
|
+
// slice and cap it; if Baidu changes class names the title/link still survive.
|
|
273
|
+
const boundary = headings[i + 1]?.at ?? Math.min(html.length, h.end + 1800);
|
|
274
|
+
const snippet = strip(html.slice(h.end, boundary)).slice(0, 240);
|
|
275
|
+
return { title: h.title, url: h.url, snippet };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** Parse Bing's stable server-rendered result list (`cn.bing.com` is reachable in the mainland network
|
|
279
|
+
* where overseas agent-search APIs commonly fail). */
|
|
280
|
+
export function parseBingSearchResults(html, limit) {
|
|
281
|
+
const strip = (s) => s
|
|
282
|
+
.replace(/<[^>]+>/g, " ")
|
|
283
|
+
.replace(/ | /gi, " ")
|
|
284
|
+
.replace(/ | /gi, " ")
|
|
285
|
+
.replace(/ | /gi, " ")
|
|
286
|
+
.replace(/&/gi, "&")
|
|
287
|
+
.replace(/</gi, "<")
|
|
288
|
+
.replace(/>/gi, ">")
|
|
289
|
+
.replace(/"/gi, '"')
|
|
290
|
+
.replace(/'|'/gi, "'")
|
|
291
|
+
.replace(/\s+/g, " ")
|
|
292
|
+
.trim();
|
|
293
|
+
const out = [];
|
|
294
|
+
const blockRe = /<li\b[^>]*class=(?:"[^"]*\bb_algo\b[^"]*"|'[^']*\bb_algo\b[^']*')[^>]*>([\s\S]*?)<\/li>/gi;
|
|
295
|
+
let block;
|
|
296
|
+
while ((block = blockRe.exec(html)) && out.length < limit) {
|
|
297
|
+
const link = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i.exec(block[1]);
|
|
298
|
+
if (!link)
|
|
299
|
+
continue;
|
|
300
|
+
const url = (link[1] ?? link[2] ?? "").replace(/&/g, "&");
|
|
301
|
+
const title = strip(link[3]);
|
|
302
|
+
if (!title || !/^https?:\/\//i.test(url))
|
|
303
|
+
continue;
|
|
304
|
+
const paragraph = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block[1]);
|
|
305
|
+
out.push({ title, url, snippet: strip(paragraph?.[1] ?? "").slice(0, 240) });
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/** Best-effort parser for Google's classic HTML result shape. Google often serves a redirect/challenge
|
|
310
|
+
* shell in mainland environments, so this is a fallback, not the sole search path. */
|
|
311
|
+
export function parseGoogleSearchResults(html, limit) {
|
|
312
|
+
const strip = (s) => s
|
|
313
|
+
.replace(/<[^>]+>/g, " ")
|
|
314
|
+
.replace(/&/gi, "&")
|
|
315
|
+
.replace(/</gi, "<")
|
|
316
|
+
.replace(/>/gi, ">")
|
|
317
|
+
.replace(/"/gi, '"')
|
|
318
|
+
.replace(/'|'/gi, "'")
|
|
319
|
+
.replace(/\s+/g, " ")
|
|
320
|
+
.trim();
|
|
321
|
+
const out = [];
|
|
322
|
+
const linkRe = /<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>[\s\S]{0,1600}?<h3\b[^>]*>([\s\S]*?)<\/h3>[\s\S]*?<\/a>/gi;
|
|
323
|
+
let m;
|
|
324
|
+
while ((m = linkRe.exec(html)) && out.length < limit) {
|
|
325
|
+
let url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
326
|
+
if (url.startsWith("/url?")) {
|
|
327
|
+
try {
|
|
328
|
+
url = new URL(url, "https://www.google.com").searchParams.get("q") ?? "";
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
url = "";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const title = strip(m[3]);
|
|
335
|
+
if (!title || !/^https?:\/\//i.test(url) || /(?:^|\.)google\.[^/]+\//i.test(url))
|
|
336
|
+
continue;
|
|
337
|
+
out.push({ title, url, snippet: "" });
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** Detect the common HTML shell returned by SPAs (root element + scripts/loading text, no readable body).
|
|
342
|
+
* web_fetch intentionally does not execute arbitrary page JavaScript, so surfacing the limitation is
|
|
343
|
+
* better than returning a misleading successful "(empty body)". */
|
|
344
|
+
export function looksLikeJsRenderedShell(html, readable) {
|
|
345
|
+
if (readable.trim().length >= 180)
|
|
346
|
+
return false;
|
|
347
|
+
const hasRoot = /<(?:div|main)\b[^>]*(?:id=["'](?:root|app|__next)["']|data-reactroot)/i.test(html);
|
|
348
|
+
const scripts = (html.match(/<script\b/gi) ?? []).length;
|
|
349
|
+
const shellText = /(?:enable javascript|javascript is required|loading[.…]*|正在加载|请启用\s*javascript)/i.test(readable || html);
|
|
350
|
+
return (hasRoot && scripts > 0) || (scripts >= 2 && readable.trim().length < 40) || shellText;
|
|
351
|
+
}
|
|
352
|
+
async function searchFetch(url, init) {
|
|
353
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
|
|
354
|
+
}
|
|
355
|
+
async function firstSuccessfulSearch(attempts, failures) {
|
|
356
|
+
// Try one provider at a time. Search terms can be sensitive; a successful request must not be mirrored
|
|
357
|
+
// to unrelated providers merely to save a few seconds, and sequential fallback also avoids leaving
|
|
358
|
+
// losing requests running after the tool has already returned.
|
|
359
|
+
for (const attempt of attempts) {
|
|
360
|
+
try {
|
|
361
|
+
const results = await attempt.run();
|
|
362
|
+
if (results.length)
|
|
363
|
+
return results;
|
|
364
|
+
failures.push(`${attempt.name} no results`);
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
|
|
368
|
+
failures.push(`${attempt.name} ${reason}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
118
373
|
registerTool({
|
|
119
374
|
name: "web_search",
|
|
120
375
|
description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
|
|
@@ -135,51 +390,90 @@ registerTool({
|
|
|
135
390
|
return "(empty query)";
|
|
136
391
|
const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
|
|
137
392
|
const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
393
|
+
const failures = [];
|
|
394
|
+
// Prefer the explicitly configured agent-search API. Only disclose the query to another provider if
|
|
395
|
+
// that request fails or returns no results; without a key, start with mainland-accessible Bing CN.
|
|
396
|
+
const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
|
|
397
|
+
const primaryAttempts = [];
|
|
398
|
+
if (key) {
|
|
399
|
+
primaryAttempts.push({
|
|
400
|
+
name: "Tavily",
|
|
401
|
+
run: async () => {
|
|
402
|
+
const res = await searchFetch("https://api.tavily.com/search", {
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: { "content-type": "application/json" },
|
|
405
|
+
body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
|
|
406
|
+
});
|
|
407
|
+
if (!res.ok)
|
|
408
|
+
throw new Error(`HTTP ${res.status}`);
|
|
151
409
|
const j = (await res.json());
|
|
152
|
-
|
|
153
|
-
if (rs.length)
|
|
154
|
-
return wrapUntrusted(fmt(rs), `web_search: ${q}`);
|
|
155
|
-
}
|
|
156
|
-
// Tavily failed → fall through to the keyless best-effort path.
|
|
157
|
-
}
|
|
158
|
-
// Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
|
|
159
|
-
const res = await fetch("https://html.duckduckgo.com/html/", {
|
|
160
|
-
method: "POST",
|
|
161
|
-
signal: ctrl.signal,
|
|
162
|
-
redirect: "follow",
|
|
163
|
-
headers: {
|
|
164
|
-
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
165
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
166
|
-
accept: "text/html",
|
|
410
|
+
return (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
|
|
167
411
|
},
|
|
168
|
-
body: `q=${encodeURIComponent(q)}`,
|
|
169
412
|
});
|
|
170
|
-
if (!res.ok)
|
|
171
|
-
return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
|
|
172
|
-
const results = parseSearchResults(await res.text(), limit);
|
|
173
|
-
if (!results.length)
|
|
174
|
-
return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
|
|
175
|
-
return wrapUntrusted(fmt(results), `web_search: ${q}`);
|
|
176
|
-
}
|
|
177
|
-
catch (e) {
|
|
178
|
-
return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
clearTimeout(timer);
|
|
182
413
|
}
|
|
414
|
+
primaryAttempts.push({
|
|
415
|
+
name: "Bing CN",
|
|
416
|
+
run: async () => {
|
|
417
|
+
const res = await searchFetch(`https://cn.bing.com/search?q=${encodeURIComponent(q)}&count=${limit}&setlang=zh-Hans`, {
|
|
418
|
+
method: "GET",
|
|
419
|
+
redirect: "follow",
|
|
420
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
421
|
+
});
|
|
422
|
+
if (!res.ok)
|
|
423
|
+
throw new Error(`HTTP ${res.status}`);
|
|
424
|
+
return parseBingSearchResults(await res.text(), limit);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
const primary = await firstSuccessfulSearch(primaryAttempts, failures);
|
|
428
|
+
if (primary)
|
|
429
|
+
return wrapUntrusted(fmt(primary), `web_search: ${q}`);
|
|
430
|
+
// Secondary sources are ordered fallbacks. Google is included where reachable, but is never the sole
|
|
431
|
+
// path because mainland networks commonly receive a challenge or JavaScript shell.
|
|
432
|
+
const secondary = await firstSuccessfulSearch([
|
|
433
|
+
{
|
|
434
|
+
name: "Baidu",
|
|
435
|
+
run: async () => {
|
|
436
|
+
const res = await searchFetch(`https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=${limit}`, {
|
|
437
|
+
method: "GET",
|
|
438
|
+
redirect: "follow",
|
|
439
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
440
|
+
});
|
|
441
|
+
if (!res.ok)
|
|
442
|
+
throw new Error(`HTTP ${res.status}`);
|
|
443
|
+
return parseBaiduSearchResults(await res.text(), limit);
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "Google",
|
|
448
|
+
run: async () => {
|
|
449
|
+
const res = await searchFetch(`https://www.google.com/search?q=${encodeURIComponent(q)}&num=${limit}&hl=zh-CN`, {
|
|
450
|
+
method: "GET",
|
|
451
|
+
redirect: "follow",
|
|
452
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
453
|
+
});
|
|
454
|
+
if (!res.ok)
|
|
455
|
+
throw new Error(`HTTP ${res.status}`);
|
|
456
|
+
return parseGoogleSearchResults(await res.text(), limit);
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "DuckDuckGo",
|
|
461
|
+
run: async () => {
|
|
462
|
+
const res = await searchFetch("https://html.duckduckgo.com/html/", {
|
|
463
|
+
method: "POST",
|
|
464
|
+
redirect: "follow",
|
|
465
|
+
headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
|
|
466
|
+
body: `q=${encodeURIComponent(q)}`,
|
|
467
|
+
});
|
|
468
|
+
if (!res.ok)
|
|
469
|
+
throw new Error(`HTTP ${res.status}`);
|
|
470
|
+
return parseSearchResults(await res.text(), limit);
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
], failures);
|
|
474
|
+
if (secondary)
|
|
475
|
+
return wrapUntrusted(fmt(secondary), `web_search: ${q}`);
|
|
476
|
+
return `Search failed across available providers (${failures.join("; ")}). Check connectivity/proxy, configure HARA_SEARCH_API_KEY, or web_fetch a known URL.`;
|
|
183
477
|
},
|
|
184
478
|
});
|
|
185
479
|
registerTool({
|
|
@@ -213,25 +507,31 @@ registerTool({
|
|
|
213
507
|
let current = url;
|
|
214
508
|
let res;
|
|
215
509
|
for (let hop = 0;; hop++) {
|
|
216
|
-
await
|
|
217
|
-
res = await
|
|
218
|
-
signal: ctrl.signal,
|
|
219
|
-
redirect: "manual",
|
|
220
|
-
headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
|
|
221
|
-
});
|
|
510
|
+
const pinned = await resolvePublicHost(current.hostname);
|
|
511
|
+
res = await requestPinned(current, pinned, ctrl.signal);
|
|
222
512
|
const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
223
513
|
if (!loc || hop >= 5)
|
|
224
514
|
break;
|
|
225
515
|
const next = new URL(loc, current);
|
|
226
|
-
if (next.protocol !== "http:" && next.protocol !== "https:")
|
|
516
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
517
|
+
res.body.destroy();
|
|
227
518
|
return "Error: redirect to a non-http(s) URL was blocked.";
|
|
519
|
+
}
|
|
520
|
+
// We never consume redirect bodies. Destroy the socket before following the next pinned hop so a
|
|
521
|
+
// server cannot accumulate idle response streams across a redirect chain.
|
|
522
|
+
res.body.destroy();
|
|
228
523
|
current = next;
|
|
229
524
|
}
|
|
230
525
|
const ct = res.headers.get("content-type") ?? "";
|
|
231
|
-
const raw = await
|
|
526
|
+
const raw = await readPinnedCapped(res.body, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
|
|
232
527
|
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
233
528
|
if (text.length > cap)
|
|
234
529
|
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
530
|
+
if (/html/i.test(ct) && looksLikeJsRenderedShell(raw, text)) {
|
|
531
|
+
const hint = "This page appears to be JavaScript-rendered; web_fetch received only the SPA shell and does not execute page scripts. " +
|
|
532
|
+
"Use an available browser/web skill for the rendered page, or the site's authenticated API/connector (for example the Feishu Docs API).";
|
|
533
|
+
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\n${hint}`, current.href)}`;
|
|
534
|
+
}
|
|
235
535
|
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
|
|
236
536
|
}
|
|
237
537
|
catch (e) {
|
package/dist/tui/run.js
CHANGED
|
@@ -4,36 +4,39 @@
|
|
|
4
4
|
import { render, Box, Text, useApp, useInput } from "ink";
|
|
5
5
|
import { createElement } from "react";
|
|
6
6
|
import { App } from "./App.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// resize events during a window drag collapses to one clear.
|
|
15
|
-
const out = process.stdout;
|
|
16
|
-
let pending = false;
|
|
7
|
+
/** Ink 6.8 clears before repainting when a terminal gets narrower, but not when it gets wider. Install a
|
|
8
|
+
* complementary WIDTH-only clear ahead of Ink's own resize listener so Ink's normal layout pass redraws
|
|
9
|
+
* the frame immediately afterwards. Never clear on a rows-only resize: `instance.clear()` erases the
|
|
10
|
+
* dynamic region without scheduling a React render, which made an idle input box disappear when a user
|
|
11
|
+
* dragged only the top/bottom edge of the terminal window. Exported for a small ordering regression test. */
|
|
12
|
+
export function installResizeRepaint(out, instance) {
|
|
13
|
+
let lastColumns = out.columns;
|
|
17
14
|
const onResize = () => {
|
|
18
|
-
|
|
15
|
+
const columns = out.columns;
|
|
16
|
+
if (!columns || columns === lastColumns)
|
|
19
17
|
return;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/* best-effort — never let a repaint fix crash the session */
|
|
28
|
-
}
|
|
29
|
-
});
|
|
18
|
+
lastColumns = columns;
|
|
19
|
+
try {
|
|
20
|
+
instance.clear();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* best-effort — never let a repaint fix crash the session */
|
|
24
|
+
}
|
|
30
25
|
};
|
|
31
|
-
|
|
26
|
+
// Ink registered its listener inside render() already. Prepending is essential: clearing AFTER Ink's
|
|
27
|
+
// repaint is precisely what leaves the prompt blank until some unrelated state update happens.
|
|
28
|
+
out.prependListener("resize", onResize);
|
|
29
|
+
return () => out.off("resize", onResize);
|
|
30
|
+
}
|
|
31
|
+
export async function runTui(props) {
|
|
32
|
+
const instance = render(createElement(App, props));
|
|
33
|
+
const out = process.stdout;
|
|
34
|
+
const removeResizeRepaint = installResizeRepaint(out, instance);
|
|
32
35
|
try {
|
|
33
36
|
await instance.waitUntilExit();
|
|
34
37
|
}
|
|
35
38
|
finally {
|
|
36
|
-
|
|
39
|
+
removeResizeRepaint();
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
// A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
|