@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. 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
- // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash) —
3
- // so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
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 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 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
- if (l === "::1" || l === "::")
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 (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
21
- return true; // link-local + unique-local
22
- const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
23
- return m ? isPrivateIp(m[1]) : false;
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
- /** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
26
- * / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
27
- async function assertPublicHost(hostname) {
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 lookup(host, { all: true });
35
- for (const a of addrs)
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(/&nbsp;|&#160;/gi, " ")
254
+ .replace(/&amp;/gi, "&")
255
+ .replace(/&lt;/gi, "<")
256
+ .replace(/&gt;/gi, ">")
257
+ .replace(/&quot;/gi, '"')
258
+ .replace(/&#39;|&#x27;/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(/&amp;/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(/&nbsp;|&#160;/gi, " ")
284
+ .replace(/&ensp;|&#8194;/gi, " ")
285
+ .replace(/&emsp;|&#8195;/gi, " ")
286
+ .replace(/&amp;/gi, "&")
287
+ .replace(/&lt;/gi, "<")
288
+ .replace(/&gt;/gi, ">")
289
+ .replace(/&quot;/gi, '"')
290
+ .replace(/&#39;|&#x27;/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(/&amp;/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(/&amp;/gi, "&")
315
+ .replace(/&lt;/gi, "<")
316
+ .replace(/&gt;/gi, ">")
317
+ .replace(/&quot;/gi, '"')
318
+ .replace(/&#39;|&#x27;/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(/&amp;/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 ctrl = new AbortController();
139
- const timer = setTimeout(() => ctrl.abort(), 20_000);
140
- try {
141
- // Reliable path: Tavily (designed for agents, free tier) when a key is configured.
142
- const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
143
- if (key) {
144
- const res = await fetch("https://api.tavily.com/search", {
145
- method: "POST",
146
- signal: ctrl.signal,
147
- headers: { "content-type": "application/json" },
148
- body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
149
- });
150
- if (res.ok) {
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
- const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
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 assertPublicHost(current.hostname);
217
- res = await fetch(current, {
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 readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
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
- export async function runTui(props) {
8
- const instance = render(createElement(App, props));
9
- // Resize repaint fix. ink 6.8's own resize handler only clears the screen when the terminal gets
10
- // NARROWER; on a WIDEN (or a resize it doesn't classify as narrowing) it just re-renders, so the old
11
- // frame reflowed at the new width is never erased, and the ~125ms spinner tick stacks a fresh copy
12
- // each time (the "moved the window and the UI stacked up" garble). We complement it: on ANY resize,
13
- // clear ink's tracked output so the next render starts clean. Debounced by a microtask so a burst of
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
- if (pending)
15
+ const columns = out.columns;
16
+ if (!columns || columns === lastColumns)
19
17
  return;
20
- pending = true;
21
- queueMicrotask(() => {
22
- pending = false;
23
- try {
24
- instance.clear();
25
- }
26
- catch {
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
- out.on("resize", onResize);
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
- out.off("resize", onResize);
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).