@nanhara/hara 0.132.3 → 0.133.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/tools/web.js CHANGED
@@ -10,6 +10,7 @@ import { request as httpsRequest } from "node:https";
10
10
  import { ProxyAgent, fetch as undiciFetch, request as undiciRequest } from "undici";
11
11
  import { wrapUntrusted } from "../security/external-content.js";
12
12
  import { loadConfig } from "../config.js";
13
+ import { renderHeadlessHtml } from "./headless-web.js";
13
14
  const MAX = 60_000;
14
15
  const SEARCH_ATTEMPT_MS = 8_000;
15
16
  const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
@@ -699,22 +700,31 @@ registerTool({
699
700
  });
700
701
  registerTool({
701
702
  name: "web_fetch",
702
- description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
703
- "Use for docs, references, or pages the user mentions. Not sandboxed.",
703
+ description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Ordinary fetches are read-only. " +
704
+ "For an SPA shell, retry with render=true to use an installed Chromium-family browser in a fresh isolated profile; " +
705
+ "rendering executes page JavaScript and therefore requires the computer-use approval boundary. Not sandboxed.",
704
706
  input_schema: {
705
707
  type: "object",
706
708
  properties: {
707
709
  url: { type: "string", description: "http:// or https:// URL" },
708
710
  max_chars: { type: "number", description: "cap on returned text (default 60000)" },
711
+ render: { type: "boolean", description: "render JavaScript in an isolated headless browser (approval required; use for SPA shells)" },
709
712
  },
710
713
  required: ["url"],
711
714
  },
712
715
  kind: "read",
713
- concurrencySafe: true,
716
+ classify(input) {
717
+ return input?.render
718
+ ? { effect: "computer", concurrencySafe: false }
719
+ : { effect: "read", concurrencySafe: true };
720
+ },
714
721
  visibility: "deferred",
715
722
  async run(input, ctx) {
716
723
  if (ctx.signal?.aborted)
717
724
  throw interrupted("web fetch");
725
+ if (input.render !== undefined && typeof input.render !== "boolean") {
726
+ return "Error: web_fetch `render` must be true or false.";
727
+ }
718
728
  let url;
719
729
  try {
720
730
  url = new URL(input.url);
@@ -776,8 +786,35 @@ registerTool({
776
786
  if (text.length > cap)
777
787
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
778
788
  if (/html/i.test(ct) && looksLikeJsRenderedShell(raw, text)) {
789
+ if (input.render === true) {
790
+ const rendered = await renderHeadlessHtml(current, async (target) => {
791
+ const approved = await resolvePublicHost(target.hostname);
792
+ const proxy = webProxy(target, ctx.cwd);
793
+ return { ...approved, ...(proxy ? { proxyUri: proxy.uri } : {}) };
794
+ }, signal);
795
+ if (ctx.signal?.aborted)
796
+ throw interrupted("web fetch");
797
+ if (rendered.html) {
798
+ let renderedText = htmlToText(rendered.html);
799
+ if (renderedText.length > cap) {
800
+ renderedText = renderedText.slice(0, cap) + `\n…[truncated ${renderedText.length - cap} chars]`;
801
+ }
802
+ if (renderedText && !looksLikeJsRenderedShell(rendered.html, renderedText)) {
803
+ return `# ${current.href} (rendered in isolated headless browser)\n\n${wrapUntrusted(renderedText, current.href)}`;
804
+ }
805
+ }
806
+ const reason = rendered.error === "browser-unavailable"
807
+ ? "No installed Chromium-family browser was found. Install Chrome/Chromium/Edge or set HARA_BROWSER_PATH to its executable."
808
+ : rendered.error === "timed-out"
809
+ ? "The isolated browser did not finish within 25 seconds."
810
+ : rendered.error === "output-too-large"
811
+ ? "The rendered DOM exceeded the 4 MiB safety ceiling."
812
+ : "The isolated browser finished without usable rendered text.";
813
+ return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\nHeadless render unavailable: ${reason}`, current.href)}`;
814
+ }
779
815
  const hint = "This page appears to be JavaScript-rendered; web_fetch received only the SPA shell and does not execute page scripts. " +
780
- "Use an available browser/web skill for the rendered page, or the site's authenticated API/connector (for example the Feishu Docs API).";
816
+ "Retry web_fetch with render=true (it uses a fresh isolated browser and requires approval), use an available browser/web skill, " +
817
+ "or use the site's authenticated API/connector (for example the Feishu Docs API).";
781
818
  return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\n${hint}`, current.href)}`;
782
819
  }
783
820
  return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
@@ -14,7 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  // of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
15
15
  // "› " prompt gutter and reflows unpredictably as you type.
16
16
  import { Box, Text, useInput, useStdout } from "ink";
17
- import { memo, useMemo, useRef, useState } from "react";
17
+ import { memo, useEffectEvent, useMemo, useRef, useState } from "react";
18
18
  import { fileCandidates } from "../context/mentions.js";
19
19
  import { imagePathFromPaste } from "../images.js";
20
20
  import { vimNormal } from "./vim.js";
@@ -476,7 +476,11 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
476
476
  setSel(0);
477
477
  setDismissed(false);
478
478
  };
479
- useInput((input, key) => {
479
+ // Ink's useInput effect depends on the callback identity. An inline callback therefore tears down and
480
+ // re-adds the stdin listener after every rendered keystroke; on a loaded/remote terminal, the next key can
481
+ // arrive after paint but before the passive effect re-subscribes and be lost. Effect Events keep one stable
482
+ // subscription while reading the latest render state, and are invoked by Ink's effect-owned listener.
483
+ const handleInput = useEffectEvent((input, key) => {
480
484
  if (popupOpen && (key.upArrow || key.downArrow)) {
481
485
  const n = candidates.length;
482
486
  setSel((s) => (key.downArrow ? (s + 1) % n : (s - 1 + n) % n));
@@ -641,7 +645,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
641
645
  }
642
646
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
643
647
  }
644
- }, { isActive });
648
+ });
649
+ useInput(handleInput, { isActive });
645
650
  const gutter = vim && mode === "normal" ? "◆ " : "› ";
646
651
  const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
647
652
  // The box borders (1 each side) + paddingX (1 each side) subtract 4 cells; InputLine's deterministic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.132.3",
3
+ "version": "0.133.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"
@@ -81,6 +81,6 @@
81
81
  },
82
82
  "@hono/node-server": "2.0.11",
83
83
  "hono": "4.12.31",
84
- "fast-uri": "3.1.3"
84
+ "fast-uri": "3.1.4"
85
85
  }
86
86
  }