@nanhara/hara 0.132.4 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.133.0 — 2026-07-22 — field-feedback closure: documents, web, recall, and setup
9
+
10
+ - A new approval-gated `python` tool sends Python 3 source directly over stdin, so one-shot APIs such as
11
+ `python-docx` no longer require Hara to create and later execute a durable `.py` helper. Source size,
12
+ active-run cancellation, process-tree timeout, output redaction, workspace sandboxing, and protected-file
13
+ preflight remain enforced; the tool reports a focused prerequisite error when Python 3 is unavailable.
14
+ - Existing user artifacts now keep their original path as the canonical output by default. Hara must not
15
+ invent “完整版”, “简版”, “new”, or similar copies unless the user explicitly requests a separate version.
16
+ Any temporary binary output used for atomic replacement must be removed on both success and failure.
17
+ - Regression coverage executes Python through stdin, proves no `.py` file is left behind, verifies protected
18
+ files cannot be reached through the new input path, and pins the in-place/no-suffix/no-helper policy in the
19
+ agent system prompt. Upgrade with `npm i -g @nanhara/hara@0.133.0`.
20
+ - The transitive `fast-uri` parser is pinned to 3.1.4, closing CVE-2026-16221/GHSA-v2hh-gcrm-f6hx's
21
+ backslash-authority host-confusion gap before URL policy and network consumers can disagree on a host.
22
+ - `--proxy <url>` and `--lang <tag>` make the existing scoped web proxy and same-language behavior visible
23
+ at launch. Proxy credentials remain config/environment-only so they never enter the process list. A user
24
+ who explicitly refers to an older chat (for example “继续上次的任务”) now triggers bounded, audience-safe
25
+ `session_search` before the model turn; ordinary prompts and explicit opt-outs do not scan transcripts.
26
+ - `web_fetch` identifies SPA shells and can retry with `render:true`. The render path requires computer-use
27
+ approval, launches an installed Chrome/Chromium/Edge with a fresh temporary profile, and forces every page,
28
+ redirect, and subresource request through a capped loopback proxy that re-applies public-IP/SSRF checks.
29
+ Browser cancellation, complete-DOM output, process-tree cleanup, and missing-browser failures are bounded.
30
+ - Package installs can explicitly switch sources with `--registry npmmirror`, global `packageRegistry`, or
31
+ `bash.registry`. Registry values are injected as environment rather than shell text and reject credentials.
32
+ Hara does not silently replay an install against a public mirror, because private scopes and supply-chain
33
+ trust must be preserved; timeout guidance now points to the explicit public-dependency recovery.
34
+ - A truly empty directory (including only `.DS_Store`/`.gitkeep`) no longer asks to generate `AGENTS.md`.
35
+ The Intel `--cwd` regression now disables update checks and uses a load-tolerant 30-second process ceiling;
36
+ product behavior remains checked by status/output rather than runner scheduling speed.
37
+ - The TUI composer now keeps one stable Ink input subscription across renders. Rapid follow-up keys (including
38
+ Up/Down history navigation on slower terminals) can no longer land between paint and listener resubscription
39
+ and disappear; the handler still observes the latest draft through React 19 Effect Events.
40
+
8
41
  ## 0.132.4 — 2026-07-22 — release-class Intel readiness handshakes
9
42
 
10
43
  - Non-Git `@path` completion now skips the bounded Git inventory subprocess when no repository marker
package/README.md CHANGED
@@ -130,6 +130,8 @@ hara config set model ...
130
130
  **Proxy for web tools in mainland/restricted networks** — only `web_fetch` and `web_search` use it:
131
131
  ```bash
132
132
  export HTTPS_PROXY=http://127.0.0.1:7890
133
+ # or for one Hara launch (do not put credentials in command-line arguments):
134
+ hara --proxy http://127.0.0.1:7890
133
135
  # or persist the same endpoint privately:
134
136
  hara config set proxy http://127.0.0.1:7890
135
137
  ```
@@ -138,6 +140,24 @@ organization enrollment, and chat gateways are not silently redirected. Even thr
138
140
  pins each DNS-approved public IP and rechecks redirects; authenticated proxy URLs are masked by
139
141
  `hara config get`.
140
142
 
143
+ When `web_fetch` receives only a JavaScript SPA shell, it tells the agent to retry with `render:true`.
144
+ That path asks for computer-use approval, uses an installed Chrome/Chromium/Edge with a fresh temporary
145
+ profile, and routes every browser request back through the same private-IP/redirect guard. Set
146
+ `HARA_BROWSER_PATH` only when the browser executable is installed in a non-standard location.
147
+
148
+ **Package registry for slow/restricted npm networks** — switching is explicit, so Hara never sends a
149
+ private scope to a public mirror without your decision:
150
+ ```bash
151
+ hara --registry npmmirror # this launch
152
+ hara config set packageRegistry npmmirror # persist globally
153
+ ```
154
+ The `bash` tool also accepts `registry:"npmmirror"` for one npm/pnpm/yarn/bun install. Use `npmjs`,
155
+ `npmmirror`, or a credential-free HTTP(S) registry URL; keep private-registry authentication in the package
156
+ manager's normal credential store. `HARA_PACKAGE_REGISTRY` is the environment equivalent.
157
+
158
+ Replies follow the latest user message's language by default. Use `hara --lang zh-CN` (or `en`) to pin one
159
+ launch, and `hara --lang auto` to restore per-message matching.
160
+
141
161
  **Vision** — hara **auto-detects** whether your main model can see images. A vision model (Claude, gpt-4o,
142
162
  qwen-vl, glm-4v…) gets pasted images **inline**. For a **text-only** model (DeepSeek, coding models), set a
143
163
  describer — the "eyes" — and hara OCRs/describes each pasted image into text first:
@@ -402,7 +422,8 @@ agent.
402
422
  ### What it can do
403
423
 
404
424
  A streaming agentic loop with built-in tools — `read_file`, `write_file`, **`edit_file`** /
405
- **`apply_patch`** (surgical edits — single file, or **atomic multi-file** changes), `bash`, and
425
+ **`apply_patch`** (surgical edits — single file, or **atomic multi-file** changes), direct-stdin
426
+ **`python`** (one-shot APIs without helper scripts), `bash`, and
406
427
  read-only **`grep`** / **`glob`** / **`ls`** / **`web_fetch`** — behind a human-in-the-loop confirmation gate on the
407
428
  dangerous ones unless `-y`. Read-only tools run in parallel within a turn, and edits print a
408
429
  **colored diff** of what changed. Shell output streams live; press **Esc** to interrupt a running
@@ -63,9 +63,16 @@ export function needsConfirm(kind, mode) {
63
63
  return kind === "exec";
64
64
  return true; // suggest: confirm edits and exec
65
65
  }
66
+ export function replyLanguageInstruction(env = process.env) {
67
+ const requested = String(env.HARA_REPLY_LANGUAGE ?? "").trim();
68
+ if (/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8}){0,3}$/u.test(requested)) {
69
+ return `Reply in ${requested} unless the user explicitly asks for another language;`;
70
+ }
71
+ return "Reply in the same language as the user's latest message unless they explicitly ask for another language;";
72
+ }
66
73
  const HARA_SYSTEM = () => `You are hara, a coding agent running in the user's terminal.
67
- Be concise and direct. Reply in the same language as the user's latest message unless they explicitly
68
- ask for another language; keep code, commands, paths, and technical identifiers unchanged. Use the
74
+ Be concise and direct. ${replyLanguageInstruction()} keep code, commands, paths, and technical identifiers
75
+ unchanged. Use the
69
76
  provided tools to read files, edit/write files, and run shell
70
77
  commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
71
78
  them whole. Batch INDEPENDENT tool calls in a single response — especially reads (read_file / grep /
@@ -76,6 +83,12 @@ grep/glob; don't read whole large files when a targeted search answers the quest
76
83
  grep to locate then read_file just that region with offset/limit — not the whole file. After a successful
77
84
  edit_file/write_file do NOT re-read the file to verify — the tool already applied and diffed the change;
78
85
  re-reading a big file after every edit is the slowest habit an agent can have.
86
+ When editing an existing user artifact, including a DOCX, keep its original path as the canonical output
87
+ and replace it in place by default. Do not invent suffix copies such as "完整版", "简版", or "new" unless
88
+ the user explicitly asks to save a separate version. For one-shot Python library work such as python-docx,
89
+ call the python tool with source directly; never write a durable helper .py file and then run it. If an
90
+ atomic binary save needs a temporary output, remove it in finally/on failure and leave only the requested
91
+ document when the task completes.
79
92
  When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
80
93
  change something (arguments / approach / tool) before trying again. After two failed variants of the same
81
94
  approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
package/dist/config.js CHANGED
@@ -76,7 +76,7 @@ export function providerCatalog() {
76
76
  ...(PROVIDER_DEFAULTS[id].baseURL ? { defaultBaseURL: PROVIDER_DEFAULTS[id].baseURL } : {}),
77
77
  }));
78
78
  }
79
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "proxy", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
79
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "proxy", "packageRegistry", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
80
80
  export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
81
81
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
82
82
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
@@ -493,6 +493,8 @@ export function loadConfig(opts = {}) {
493
493
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
494
494
  const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
495
495
  const proxy = typeof merged.proxy === "string" && merged.proxy.trim() ? merged.proxy.trim() : undefined;
496
+ const packageRegistry = nonBlankEnv(process.env.HARA_PACKAGE_REGISTRY)
497
+ ?? (typeof merged.packageRegistry === "string" && merged.packageRegistry.trim() ? merged.packageRegistry.trim() : undefined);
496
498
  const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
497
499
  const requestedFallbackProvider = nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider;
498
500
  const fallbackProvider = isProviderId(requestedFallbackProvider) ? requestedFallbackProvider : undefined;
@@ -502,7 +504,7 @@ export function loadConfig(opts = {}) {
502
504
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
503
505
  ? reasoningRaw
504
506
  : undefined;
505
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, proxy, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
507
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, proxy, packageRegistry, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
506
508
  }
507
509
  export function providerEnvKey(provider) {
508
510
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -1,6 +1,6 @@
1
1
  // Project-context loading (AGENTS.md) — the cross-tool standard read by Codex/Claude Code/OpenClaw.
2
2
  // Walks up from cwd to the project root, concatenates AGENTS.md files, caps total size.
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readdirSync } from "node:fs";
4
4
  import { join, dirname, resolve } from "node:path";
5
5
  import { readModelContextBytePrefixSync } from "../fs-read.js";
6
6
  import { homeWorkspaceGuidance, isHomeWorkspace } from "./workspace-scope.js";
@@ -110,6 +110,23 @@ export function hasAgentsMd(cwd) {
110
110
  const root = findProjectRoot(cwd);
111
111
  return FILENAMES.some((n) => existsSync(join(root, n)));
112
112
  }
113
+ /** An empty directory has nothing useful for the init agent to analyze. Do not interrupt first launch with
114
+ * an AGENTS.md offer until the user has created a project marker or at least one visible project file. */
115
+ export function hasProjectContent(cwd) {
116
+ const root = findProjectRoot(cwd);
117
+ try {
118
+ return readdirSync(root, { withFileTypes: true }).some((entry) => {
119
+ if (ROOT_MARKERS.includes(entry.name))
120
+ return true;
121
+ if (entry.name === ".DS_Store" || entry.name === ".gitkeep")
122
+ return false;
123
+ return !entry.name.startsWith(".");
124
+ });
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
113
130
  /** Prompt hara runs against itself to analyze the repo and write AGENTS.md. */
114
131
  export const INIT_PROMPT = "Explore this repository to understand it, then write a concise AGENTS.md at the project root.\n" +
115
132
  "Use read_file, bash (e.g. `ls`, `git ls-files`, `cat`), and write_file.\n" +
package/dist/exec/jobs.js CHANGED
@@ -50,7 +50,7 @@ function ensureExitCleanup() {
50
50
  process.on("exit", killAllJobs); // sync; runs on normal quit so background jobs don't orphan
51
51
  }
52
52
  /** Start a background shell job; returns its id immediately. Output is captured to a capped tail buffer. */
53
- export function startJob(command, cwd, mode) {
53
+ export function startJob(command, cwd, mode, env = {}) {
54
54
  ensureExitCleanup();
55
55
  const running = [...jobs.values()].filter((job) => job.status === "running" || job.terminationPending).length;
56
56
  if (running >= MAX_RUNNING_JOBS) {
@@ -59,7 +59,7 @@ export function startJob(command, cwd, mode) {
59
59
  pruneFinishedJobs(1);
60
60
  const { cmd, args } = shellCommand(command, cwd, mode);
61
61
  const processGroup = platform() !== "win32";
62
- const child = spawn(cmd, args, { cwd, env: toolSubprocessEnv(), detached: processGroup });
62
+ const child = spawn(cmd, args, { cwd, env: toolSubprocessEnv(process.env, env), detached: processGroup });
63
63
  const job = {
64
64
  id: "j" + ++seq,
65
65
  // The raw command may contain an inline --token/KEY=value. It is used only for spawning above; every
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ import { listJobs, tailJob, killJob } from "./exec/jobs.js";
56
56
  import { readModelContextFileSync } from "./fs-read.js";
57
57
  import { MIN_NODE_VERSION, unsupportedNodeMessage } from "./runtime.js";
58
58
  import { redactKnownSecrets } from "./security/secrets.js";
59
+ import { normalizePackageRegistry } from "./package-registry.js";
59
60
  /** Render the background-job list for /jobs (user-facing view of what the agent has running in the
60
61
  * background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
61
62
  function renderBgJobs() {
@@ -71,7 +72,7 @@ function renderBgJobs() {
71
72
  return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
72
73
  }
73
74
  import { qwenDeviceLogin, loadQwenToken } from "./providers/qwen-oauth.js";
74
- import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
75
+ import { loadAgentContext, hasAgentsMd, hasProjectContent, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
75
76
  import { homeWorkspaceActionError, discoverProjectWorkspaces, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, suggestedProjectWorkspace, } from "./context/workspace-scope.js";
76
77
  import { getEmbedder } from "./search/embed.js";
77
78
  import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
@@ -96,7 +97,7 @@ import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
96
97
  import { c, out, statusLine } from "./ui.js";
97
98
  import * as bar from "./statusbar.js";
98
99
  import { nearest } from "./fuzzy.js";
99
- import "./tools/builtin.js"; // register read_file/write_file/bash
100
+ import "./tools/builtin.js"; // register read_file/write_file/python/bash/job
100
101
  import "./tools/runtime.js"; // register tool_search/tool_result_read
101
102
  import "./tools/edit.js"; // register edit_file
102
103
  import "./tools/search.js"; // register grep/glob/ls
@@ -104,7 +105,7 @@ import "./tools/patch.js"; // register apply_patch
104
105
  import "./tools/web.js"; // register web_fetch
105
106
  import "./tools/agent.js"; // register agent (subagent spawn)
106
107
  import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
107
- import "./tools/session-search.js"; // register bounded cross-session transcript recall
108
+ import { automaticSessionRecall } from "./tools/session-search.js"; // register + deterministic explicit-cue recall
108
109
  import "./tools/skill.js"; // register the skill loader tool
109
110
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
110
111
  import "./tools/todo.js"; // register todo_write (inline task checklist)
@@ -1396,6 +1397,9 @@ program
1396
1397
  .option("--profile <id>", "use this identity profile for this run (personal / org id) — see `hara profile list`")
1397
1398
  .option("--overlay <name>", "apply a named config overlay from ~/.hara/config.json (legacy: --profile)")
1398
1399
  .option("--cwd <dir>", "run from this explicit project directory (alternative to cd)")
1400
+ .option("--proxy <url>", "HTTP(S) proxy for web_fetch/web_search in this run (credentials belong in config/env)")
1401
+ .option("--registry <url>", "package registry for installs in this run: npmjs, npmmirror, or an HTTP(S) URL")
1402
+ .option("--lang <tag>", "reply language for this run, for example zh-CN or en (default: follow latest message)")
1399
1403
  .option("-c, --continue", "resume the most recent session in this directory")
1400
1404
  .option("--resume <id>", "resume a specific session by id")
1401
1405
  .option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
@@ -1419,6 +1423,46 @@ program.hook("preAction", (thisCmd) => {
1419
1423
  process.exit(2);
1420
1424
  }
1421
1425
  }
1426
+ const proxyFlag = thisCmd.opts().proxy;
1427
+ if (proxyFlag) {
1428
+ try {
1429
+ const parsed = new URL(proxyFlag);
1430
+ if (!(parsed.protocol === "http:" || parsed.protocol === "https:")
1431
+ || parsed.pathname !== "/"
1432
+ || parsed.search
1433
+ || parsed.hash
1434
+ || parsed.username
1435
+ || parsed.password)
1436
+ throw new Error("invalid or credential-bearing proxy URL");
1437
+ process.env.HARA_WEB_PROXY = parsed.origin;
1438
+ }
1439
+ catch {
1440
+ out(c.red("Cannot use --proxy: provide an HTTP(S) origin such as http://127.0.0.1:7890. Put authenticated proxy URLs in `hara config set proxy …` or HTTPS_PROXY so credentials do not enter the process list.\n"));
1441
+ process.exit(2);
1442
+ }
1443
+ }
1444
+ const languageFlag = thisCmd.opts().lang;
1445
+ if (languageFlag) {
1446
+ if (languageFlag === "auto")
1447
+ delete process.env.HARA_REPLY_LANGUAGE;
1448
+ else if (/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8}){0,3}$/u.test(languageFlag)) {
1449
+ process.env.HARA_REPLY_LANGUAGE = languageFlag;
1450
+ }
1451
+ else {
1452
+ out(c.red("Cannot use --lang: provide a language tag such as zh-CN or en, or use auto.\n"));
1453
+ process.exit(2);
1454
+ }
1455
+ }
1456
+ const registryFlag = thisCmd.opts().registry;
1457
+ if (registryFlag) {
1458
+ try {
1459
+ process.env.HARA_PACKAGE_REGISTRY = normalizePackageRegistry(registryFlag);
1460
+ }
1461
+ catch {
1462
+ out(c.red("Cannot use --registry: provide npmjs, npmmirror, or an HTTP(S) registry URL without credentials/query/fragment.\n"));
1463
+ process.exit(2);
1464
+ }
1465
+ }
1422
1466
  const flag = thisCmd.opts().profile;
1423
1467
  if (!flag)
1424
1468
  return;
@@ -3173,6 +3217,17 @@ config
3173
3217
  process.exit(1);
3174
3218
  }
3175
3219
  }
3220
+ if (key === "packageRegistry") {
3221
+ try {
3222
+ value = normalizePackageRegistry(value) ?? "";
3223
+ if (!value)
3224
+ throw new Error("empty registry");
3225
+ }
3226
+ catch {
3227
+ out(c.red("Invalid package registry. Use npmjs, npmmirror, or an HTTP(S) URL without credentials, query parameters, or a fragment.\n"));
3228
+ process.exit(1);
3229
+ }
3230
+ }
3176
3231
  if (key === "runTimeoutMs") {
3177
3232
  const parsed = parseAgentRunTimeoutMs(value);
3178
3233
  if (parsed === undefined || parsed < MIN_AGENT_RUN_TIMEOUT_MS || parsed > MAX_AGENT_RUN_TIMEOUT_MS) {
@@ -3667,7 +3722,13 @@ program.action(async (opts) => {
3667
3722
  // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
3668
3723
  // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
3669
3724
  // visionModel sidecar and folded into the message (text-only models can't take image blocks).
3670
- const userText = await expandMentionsAsync(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
3725
+ const printText = String(opts.print);
3726
+ const automaticRecall = meta
3727
+ ? await automaticSessionRecall(printText, { cwd, sessionId: meta.id })
3728
+ : "";
3729
+ const userText = (automaticRecall ? `${automaticRecall}\n\n---\n\n` : "")
3730
+ + await expandMentionsAsync(printText, cwd)
3731
+ + (schemaObj ? STRUCTURED_INSTRUCTION : "");
3671
3732
  const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
3672
3733
  .split("\n")
3673
3734
  .map((s) => s.trim())
@@ -3910,7 +3971,7 @@ program.action(async (opts) => {
3910
3971
  // First-run AGENTS.md offer — classic REPL only. In TUI mode we must NOT call rl.question before ink
3911
3972
  // mounts: a readline question puts stdin in a state ink can't read from, leaving the input box dead
3912
3973
  // (the TUI shows a `/init` tip instead, below). See the `tip` in the runTui header.
3913
- if (!homeWorkspace && !hasAgentsMd(cwd) && !useTui) {
3974
+ if (!homeWorkspace && !hasAgentsMd(cwd) && hasProjectContent(cwd) && !useTui) {
3914
3975
  const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
3915
3976
  if (ans === "" || ans.startsWith("y")) {
3916
3977
  out(c.dim("Analyzing project…\n"));
@@ -4565,7 +4626,7 @@ program.action(async (opts) => {
4565
4626
  // First-run AGENTS.md offer — via a tiny ink prompt, NOT readline. A readline question before the
4566
4627
  // main TUI leaves stdin unreadable by ink (dead input box); ink cleans up on unmount, so the TUI
4567
4628
  // mounted right after gets working input. Runs before mount, like the classic path.
4568
- if (!homeWorkspace && !hasAgentsMd(cwd)) {
4629
+ if (!homeWorkspace && !hasAgentsMd(cwd) && hasProjectContent(cwd)) {
4569
4630
  if (await askConfirm("No AGENTS.md here — analyze this project and create one?")) {
4570
4631
  out(c.dim("Analyzing project…\n"));
4571
4632
  try {
@@ -5183,7 +5244,9 @@ program.action(async (opts) => {
5183
5244
  const planImg = await resolveImages(images, h);
5184
5245
  if (planImg.skip)
5185
5246
  return;
5186
- const planContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? "");
5247
+ const automaticRecall = await automaticSessionRecall(line, { cwd, sessionId: meta.id, signal: h.signal });
5248
+ const recallPrefix = [recalledContext, automaticRecall].filter(Boolean).join("\n\n");
5249
+ const planContent = (recallPrefix ? `${recallPrefix}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? "");
5187
5250
  const executionContext = beginExecution(line);
5188
5251
  if (!executionContext)
5189
5252
  return;
@@ -5299,7 +5362,9 @@ program.action(async (opts) => {
5299
5362
  const ri = await resolveImages(images, h);
5300
5363
  if (ri.skip)
5301
5364
  return;
5302
- const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (ri.extraText ?? "");
5365
+ const automaticRecall = await automaticSessionRecall(line, { cwd, sessionId: meta.id, signal: h.signal });
5366
+ const recallPrefix = [recalledContext, automaticRecall].filter(Boolean).join("\n\n");
5367
+ const userContent = (recallPrefix ? `${recallPrefix}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (ri.extraText ?? "");
5303
5368
  recalledContext = "";
5304
5369
  const executionContext = beginExecution(line);
5305
5370
  if (!executionContext)
@@ -5440,7 +5505,9 @@ program.action(async (opts) => {
5440
5505
  continue;
5441
5506
  }
5442
5507
  line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
5443
- const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
5508
+ const automaticRecall = await automaticSessionRecall(line, { cwd, sessionId: meta.id });
5509
+ const recallPrefix = [recalledContext, automaticRecall].filter(Boolean).join("\n\n");
5510
+ const userContent = (recallPrefix ? `${recallPrefix}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
5444
5511
  recalledContext = "";
5445
5512
  const recoveredClassicSteering = consumePendingTaskSteering(task);
5446
5513
  if (recoveredClassicSteering) {
@@ -0,0 +1,43 @@
1
+ /** Explicit package-registry routing for npm/pnpm/yarn/bun installs. Registry switching is user-selected:
2
+ * silently replaying an install against a public mirror can break private scopes and changes the software
3
+ * supply-chain trust boundary. */
4
+ export function normalizePackageRegistry(value) {
5
+ const raw = String(value ?? "").trim();
6
+ if (!raw)
7
+ return undefined;
8
+ const expanded = raw === "npmjs"
9
+ ? "https://registry.npmjs.org/"
10
+ : raw === "npmmirror"
11
+ ? "https://registry.npmmirror.com/"
12
+ : raw;
13
+ let url;
14
+ try {
15
+ url = new URL(expanded);
16
+ }
17
+ catch {
18
+ throw new Error("package registry must be npmjs, npmmirror, or an absolute HTTP(S) URL");
19
+ }
20
+ if ((url.protocol !== "http:" && url.protocol !== "https:")
21
+ || !url.hostname
22
+ || url.username
23
+ || url.password
24
+ || url.search
25
+ || url.hash) {
26
+ throw new Error("package registry must be an HTTP(S) URL without credentials, query parameters, or a fragment");
27
+ }
28
+ if (!url.pathname.endsWith("/"))
29
+ url.pathname += "/";
30
+ return url.href;
31
+ }
32
+ export function commandHasPackageRegistry(command) {
33
+ return /(?:^|\s)--registry(?:=|\s|$)/iu.test(command);
34
+ }
35
+ /** The common npm config variable is honored by npm and pnpm; explicit Yarn/Bun variables cover their
36
+ * modern clients too. Values are injected as environment, never interpolated into a shell command. */
37
+ export function packageRegistryEnv(registry) {
38
+ return {
39
+ NPM_CONFIG_REGISTRY: registry,
40
+ YARN_NPM_REGISTRY_SERVER: registry,
41
+ BUN_CONFIG_REGISTRY: registry,
42
+ };
43
+ }
package/dist/tools/all.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // imports itself; library entries (serve/server.ts, future embedders) import THIS so the registry is
3
3
  // never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
4
4
  // as "the model called write_file and nothing happened".
5
- import "./builtin.js"; // read_file / write_file / bash / job
5
+ import "./builtin.js"; // read_file / write_file / python / bash / job
6
6
  import "./runtime.js"; // tool_search / tool_result_read
7
7
  import "./edit.js"; // edit_file
8
8
  import "./search.js"; // grep / glob / ls
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
2
+ import { homedir, platform } from "node:os";
3
3
  import { resolve, isAbsolute } from "node:path";
4
4
  import { stdout as procOut } from "node:process";
5
5
  import { registerTool } from "./registry.js";
@@ -14,8 +14,16 @@ import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
14
14
  import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
15
15
  import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
16
16
  import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
17
+ import { loadConfig } from "../config.js";
18
+ import { commandHasPackageRegistry, normalizePackageRegistry, packageRegistryEnv } from "../package-registry.js";
17
19
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
18
20
  const MAX = 100_000;
21
+ const MAX_PYTHON_SOURCE_BYTES = 500_000;
22
+ /** Python source is delivered over stdin, so one-shot library operations never need a durable helper
23
+ * script. Keep the command itself fixed: model-authored source must not be interpolated into a shell. */
24
+ export function pythonStdinCommand(plat = platform()) {
25
+ return plat === "win32" ? "py -3 -" : "python3 -";
26
+ }
19
27
  /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
20
28
  export function isPackageInstallCommand(command) {
21
29
  return /(?:^|[;&|]\s*)(?:npm\s+(?:i|install|ci)\b|pnpm\s+(?:i|install|add)\b|yarn(?:\s+(?:install|add))?(?:\s|$)|bun\s+(?:i|install|add)\b)/i.test(command.trim());
@@ -149,7 +157,9 @@ registerTool({
149
157
  });
150
158
  registerTool({
151
159
  name: "write_file",
152
- description: "Create or overwrite a UTF-8 text file (creates parent directories).",
160
+ description: "Create or overwrite a UTF-8 text file (creates parent directories). This is text-only: do not use " +
161
+ "it for binary Office files or merely to save a one-shot Python helper before executing it; send " +
162
+ "one-shot Python source directly to the python tool instead.",
153
163
  input_schema: {
154
164
  type: "object",
155
165
  properties: {
@@ -200,6 +210,54 @@ registerTool({
200
210
  return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
201
211
  },
202
212
  });
213
+ registerTool({
214
+ name: "python",
215
+ description: "Execute Python 3 source directly from this tool input over stdin; no .py helper file is created. " +
216
+ "Prefer this for one-shot library APIs such as python-docx. When editing an existing user document, " +
217
+ "keep the original path as the canonical output unless the user explicitly requests a copy/version. " +
218
+ "Use a temporary output only for atomic replacement and remove it in finally/on failure.",
219
+ input_schema: {
220
+ type: "object",
221
+ properties: {
222
+ code: { type: "string", description: "Python 3 source executed from stdin, never written to a .py file" },
223
+ timeout_ms: { type: "number", description: "default 300000 (5 min), bounded to 1s..1h" },
224
+ },
225
+ required: ["code"],
226
+ },
227
+ kind: "exec",
228
+ requiresProjectWorkspace: true,
229
+ async run(input, ctx) {
230
+ if (typeof input.code !== "string" || !input.code.trim()) {
231
+ return "Error: python `code` must be a non-empty string. Nothing executed.";
232
+ }
233
+ if (Buffer.byteLength(input.code, "utf8") > MAX_PYTHON_SOURCE_BYTES) {
234
+ return `Error: python source exceeds ${MAX_PYTHON_SOURCE_BYTES} bytes. Split the operation into smaller direct calls; do not create a helper script.`;
235
+ }
236
+ const protectedReason = sensitiveShellCommandReason(input.code, ctx.cwd);
237
+ if (protectedReason) {
238
+ return (`Blocked: Python source crosses Hara's protected secret boundary (${protectedReason}). ` +
239
+ "This deny is not bypassed by direct stdin execution or full-auto.");
240
+ }
241
+ const command = pythonStdinCommand();
242
+ try {
243
+ const result = await runShell(command, ctx.cwd, ctx.sandbox ?? "off", {
244
+ timeout: shellTimeoutMs(command, input.timeout_ms),
245
+ maxBuffer: 1_000_000,
246
+ signal: ctx.signal,
247
+ input: input.code,
248
+ });
249
+ const rendered = redactToolSubprocessOutput(capHeadTail([result.stdout, result.stderr].filter(Boolean).join("\n"))).trim();
250
+ return rendered ? `Python completed without creating a helper script.\n${rendered}` : "Python completed without creating a helper script.";
251
+ }
252
+ catch (error) {
253
+ const rendered = redactToolSubprocessOutput(capHeadTail([error?.stdout, error?.stderr].filter(Boolean).join("\n"))).trim();
254
+ if (error?.code === 127) {
255
+ return "Error: Python 3 is not available on PATH. Install Python 3 and the required library, then retry; no helper script was created.";
256
+ }
257
+ return `Python failed: ${error?.message ?? String(error)}.${rendered ? `\n${rendered}` : ""}`;
258
+ }
259
+ },
260
+ });
203
261
  registerTool({
204
262
  name: "bash",
205
263
  description: "Run a shell command in the working directory; returns combined stdout/stderr.",
@@ -209,6 +267,7 @@ registerTool({
209
267
  command: { type: "string" },
210
268
  timeout_ms: { type: "number", description: "default 300000 (5 min), or 900000 (15 min) for package installs; bounded to 1s..1h" },
211
269
  background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); package installs stay foreground unless explicitly requested" },
270
+ registry: { type: "string", description: "for package installs only: npmjs, npmmirror, or an HTTP(S) registry URL; injected as environment, never shell text" },
212
271
  },
213
272
  required: ["command"],
214
273
  },
@@ -228,6 +287,25 @@ registerTool({
228
287
  if (input.background !== undefined && typeof input.background !== "boolean") {
229
288
  return "Error: `background` must be a boolean (true or false), not a string or another truthy value.";
230
289
  }
290
+ const packageInstall = isPackageInstallCommand(String(input.command ?? ""));
291
+ if (input.registry !== undefined && typeof input.registry !== "string") {
292
+ return "Error: `registry` must be a string such as npmmirror or https://registry.npmjs.org/.";
293
+ }
294
+ if (input.registry !== undefined && !packageInstall) {
295
+ return "Error: `registry` applies only to npm/pnpm/yarn/bun install commands.";
296
+ }
297
+ if (input.registry !== undefined && commandHasPackageRegistry(String(input.command ?? ""))) {
298
+ return "Error: choose one package-registry control: remove either bash.registry or the command's --registry argument.";
299
+ }
300
+ let registry;
301
+ if (packageInstall && !commandHasPackageRegistry(String(input.command ?? ""))) {
302
+ try {
303
+ registry = normalizePackageRegistry(input.registry ?? loadConfig({ cwd: ctx.cwd }).packageRegistry);
304
+ }
305
+ catch (error) {
306
+ return `Error: invalid package registry: ${error?.message ?? "unsupported value"}. Nothing executed.`;
307
+ }
308
+ }
231
309
  const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
232
310
  if (protectedReason) {
233
311
  return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
@@ -238,9 +316,11 @@ registerTool({
238
316
  "Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
239
317
  }
240
318
  if (input.background) {
241
- const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
319
+ const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off", registry ? packageRegistryEnv(registry) : {});
242
320
  const safeCommand = redactToolSubprocessOutput(String(input.command));
243
- return `Started background job ${id}: \`${safeCommand}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
321
+ return `Started background job ${id}: \`${safeCommand}\`.` +
322
+ (registry ? " Explicit package registry override applied." : "") +
323
+ ` Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
244
324
  }
245
325
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
246
326
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
@@ -278,10 +358,14 @@ registerTool({
278
358
  maxBuffer: 10 * 1024 * 1024,
279
359
  onData: live,
280
360
  signal: ctx.signal,
361
+ ...(registry ? { env: packageRegistryEnv(registry) } : {}),
281
362
  });
282
363
  flushLive();
283
364
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
284
- return capHeadTail(redactToolSubprocessOutput(combined.trim() || "(no output)"));
365
+ // The endpoint can be a private hostname/path. Confirmation is useful, but echoing it back into the
366
+ // model transcript is unnecessary and may expose organization-specific routing metadata.
367
+ const notice = registry ? "hara: explicit package registry override applied\n" : "";
368
+ return capHeadTail(redactToolSubprocessOutput(notice + (combined.trim() || "(no output)")));
285
369
  }
286
370
  catch (e) {
287
371
  flushLive();
@@ -293,6 +377,11 @@ registerTool({
293
377
  `\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
294
378
  `a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
295
379
  `a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
380
+ if (packageInstall && !registry && !commandHasPackageRegistry(String(input.command ?? ""))) {
381
+ base +=
382
+ " For public npm dependencies, an explicit retry may set bash.registry=\"npmmirror\" (or launch with --registry npmmirror). " +
383
+ "Hara does not silently redirect installs because private scopes and registry trust must be preserved.";
384
+ }
296
385
  }
297
386
  // Network fault tolerance — if this was a genuine host-unreachability (connect timeout / DNS, NOT
298
387
  // auth / 404 / connection-refused), remember the host so we fast-fail future ops to it this session.
@@ -0,0 +1,417 @@
1
+ // Optional isolated headless rendering for web_fetch. The browser uses a fresh temporary profile and is
2
+ // forced through a loopback validating proxy: every top-level/subresource destination is resolved once,
3
+ // rejected if any answer is private/internal, and connected by the approved IP. This preserves web_fetch's
4
+ // SSRF boundary across JavaScript redirects instead of handing Chrome an unchecked public URL.
5
+ import { spawn } from "node:child_process";
6
+ import { once } from "node:events";
7
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
8
+ import { createServer, request as httpRequest, } from "node:http";
9
+ import { request as httpsRequest } from "node:https";
10
+ import { createConnection } from "node:net";
11
+ import { tmpdir, platform } from "node:os";
12
+ import { delimiter, isAbsolute, join } from "node:path";
13
+ import { connect as tlsConnect } from "node:tls";
14
+ import { terminateSubprocessTree, toolSubprocessEnv } from "../security/subprocess-env.js";
15
+ const MAX_BROWSER_REQUESTS = 192;
16
+ const MAX_BROWSER_BYTES = 64 * 1024 * 1024;
17
+ const MAX_BROWSER_HTML_BYTES = 4 * 1024 * 1024;
18
+ const BROWSER_TIMEOUT_MS = 25_000;
19
+ const BROWSER_SOCKET_TIMEOUT_MS = 12_000;
20
+ function executableCandidates(env, plat) {
21
+ const explicit = String(env.HARA_BROWSER_PATH ?? "").trim();
22
+ const fixed = plat === "darwin"
23
+ ? [
24
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
27
+ ]
28
+ : plat === "win32"
29
+ ? [
30
+ env.ProgramFiles && join(env.ProgramFiles, "Google/Chrome/Application/chrome.exe"),
31
+ env["ProgramFiles(x86)"] && join(env["ProgramFiles(x86)"], "Google/Chrome/Application/chrome.exe"),
32
+ env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Google/Chrome/Application/chrome.exe"),
33
+ env.ProgramFiles && join(env.ProgramFiles, "Microsoft/Edge/Application/msedge.exe"),
34
+ ]
35
+ : [
36
+ "/usr/bin/google-chrome",
37
+ "/usr/bin/google-chrome-stable",
38
+ "/usr/bin/chromium",
39
+ "/usr/bin/chromium-browser",
40
+ "/usr/bin/microsoft-edge",
41
+ ];
42
+ const names = plat === "win32"
43
+ ? ["chrome.exe", "msedge.exe", "chromium.exe"]
44
+ : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
45
+ const onPath = String(env.PATH ?? "")
46
+ .split(delimiter)
47
+ .filter(Boolean)
48
+ .flatMap((dir) => names.map((name) => join(dir, name)));
49
+ return [explicit && isAbsolute(explicit) ? explicit : "", ...fixed, ...onPath]
50
+ .filter((value) => !!value);
51
+ }
52
+ /** Find an already-installed Chromium-family browser. Hara never downloads or mutates a browser install. */
53
+ export function findHeadlessBrowser(env = process.env, plat = platform()) {
54
+ return executableCandidates(env, plat).find((path) => {
55
+ try {
56
+ return existsSync(path);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ }
63
+ function safeHeaders(headers) {
64
+ const result = {};
65
+ const dropped = new Set([
66
+ "connection", "proxy-authorization", "proxy-authenticate", "proxy-connection", "keep-alive",
67
+ "te", "trailer", "transfer-encoding", "upgrade",
68
+ ]);
69
+ for (const [name, value] of Object.entries(headers)) {
70
+ if (value === undefined || dropped.has(name.toLowerCase()))
71
+ continue;
72
+ result[name] = value;
73
+ }
74
+ return result;
75
+ }
76
+ function proxyAuth(proxy) {
77
+ if (!proxy.username && !proxy.password)
78
+ return undefined;
79
+ return `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`;
80
+ }
81
+ function pinnedAuthority(route, port) {
82
+ return `${route.family === 6 ? `[${route.address}]` : route.address}:${port}`;
83
+ }
84
+ function parseConnectTarget(authority) {
85
+ if (!authority || /[\s/@?#]/u.test(authority))
86
+ throw new Error("invalid CONNECT target");
87
+ const target = new URL(`https://${authority}`);
88
+ if (!target.hostname || target.username || target.password || target.pathname !== "/")
89
+ throw new Error("invalid CONNECT target");
90
+ return target;
91
+ }
92
+ function requestThroughUpstream(proxyUri, target, route, req, res, countBytes) {
93
+ const proxy = new URL(proxyUri);
94
+ if (proxy.protocol !== "http:" && proxy.protocol !== "https:")
95
+ throw new Error("unsupported upstream proxy");
96
+ const pinned = new URL(target.href);
97
+ pinned.hostname = route.family === 6 ? `[${route.address}]` : route.address;
98
+ pinned.username = "";
99
+ pinned.password = "";
100
+ const headers = { ...safeHeaders(req.headers), host: target.host };
101
+ const auth = proxyAuth(proxy);
102
+ if (auth)
103
+ headers["proxy-authorization"] = auth;
104
+ const options = {
105
+ protocol: proxy.protocol,
106
+ hostname: proxy.hostname,
107
+ port: proxy.port || undefined,
108
+ method: req.method,
109
+ path: pinned.href,
110
+ headers,
111
+ ...(proxy.protocol === "https:" ? { servername: proxy.hostname } : {}),
112
+ };
113
+ const upstream = (proxy.protocol === "https:" ? httpsRequest : httpRequest)(options, (remote) => {
114
+ res.writeHead(remote.statusCode ?? 502, remote.headers);
115
+ remote.once("error", () => { if (!res.destroyed)
116
+ res.end(); });
117
+ remote.on("data", (chunk) => {
118
+ if (!countBytes(chunk.length))
119
+ remote.destroy();
120
+ });
121
+ remote.pipe(res);
122
+ });
123
+ upstream.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => upstream.destroy(new Error("render proxy timeout")));
124
+ res.once("close", () => upstream.destroy());
125
+ upstream.once("error", () => {
126
+ if (!res.headersSent)
127
+ res.writeHead(502);
128
+ res.end("render proxy request failed");
129
+ });
130
+ req.pipe(upstream);
131
+ }
132
+ function requestDirect(target, route, req, res, countBytes) {
133
+ const upstream = httpRequest({
134
+ protocol: "http:",
135
+ hostname: route.address,
136
+ family: route.family,
137
+ port: target.port || 80,
138
+ method: req.method,
139
+ path: `${target.pathname}${target.search}`,
140
+ headers: { ...safeHeaders(req.headers), host: target.host },
141
+ }, (remote) => {
142
+ res.writeHead(remote.statusCode ?? 502, remote.headers);
143
+ remote.once("error", () => { if (!res.destroyed)
144
+ res.end(); });
145
+ remote.on("data", (chunk) => {
146
+ if (!countBytes(chunk.length))
147
+ remote.destroy();
148
+ });
149
+ remote.pipe(res);
150
+ });
151
+ upstream.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => upstream.destroy(new Error("render destination timeout")));
152
+ res.once("close", () => upstream.destroy());
153
+ upstream.once("error", () => {
154
+ if (!res.headersSent)
155
+ res.writeHead(502);
156
+ res.end("render proxy request failed");
157
+ });
158
+ req.pipe(upstream);
159
+ }
160
+ async function upstreamTunnel(proxyUri, authority, onSocket) {
161
+ const proxy = new URL(proxyUri);
162
+ if (proxy.protocol !== "http:" && proxy.protocol !== "https:")
163
+ throw new Error("unsupported upstream proxy");
164
+ const port = Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80));
165
+ const socket = proxy.protocol === "https:"
166
+ ? tlsConnect({ host: proxy.hostname, port, servername: proxy.hostname })
167
+ : createConnection({ host: proxy.hostname, port });
168
+ onSocket(socket);
169
+ socket.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => socket.destroy(new Error("upstream proxy timeout")));
170
+ await once(socket, proxy.protocol === "https:" ? "secureConnect" : "connect");
171
+ const auth = proxyAuth(proxy);
172
+ socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n` +
173
+ (auth ? `Proxy-Authorization: ${auth}\r\n` : "") +
174
+ "Connection: keep-alive\r\n\r\n");
175
+ return await new Promise((resolve, reject) => {
176
+ let buffered = Buffer.alloc(0);
177
+ const fail = () => reject(new Error("upstream proxy tunnel failed"));
178
+ const onData = (chunk) => {
179
+ buffered = Buffer.concat([buffered, chunk]);
180
+ if (buffered.length > 16 * 1024)
181
+ return fail();
182
+ const boundary = buffered.indexOf("\r\n\r\n");
183
+ if (boundary < 0)
184
+ return;
185
+ socket.off("data", onData);
186
+ socket.off("error", fail);
187
+ const status = Number(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/iu.exec(buffered.subarray(0, boundary).toString("latin1"))?.[1] ?? 0);
188
+ if (status < 200 || status >= 300)
189
+ return fail();
190
+ socket.setTimeout(BROWSER_TIMEOUT_MS, () => socket.destroy());
191
+ resolve({ socket, leftover: buffered.subarray(boundary + 4) });
192
+ };
193
+ socket.on("data", onData);
194
+ socket.once("error", fail);
195
+ }).catch((error) => {
196
+ socket.destroy();
197
+ throw error;
198
+ });
199
+ }
200
+ async function startValidatingProxy(resolveRoute) {
201
+ const sockets = new Set();
202
+ let requestCount = 0;
203
+ let byteCount = 0;
204
+ let closed = false;
205
+ const countRequest = () => !closed && ++requestCount <= MAX_BROWSER_REQUESTS;
206
+ const countBytes = (amount) => !closed && (byteCount += amount) <= MAX_BROWSER_BYTES;
207
+ const server = createServer(async (req, res) => {
208
+ if (!countRequest()) {
209
+ res.writeHead(429);
210
+ return void res.end("render request budget exceeded");
211
+ }
212
+ try {
213
+ const target = new URL(String(req.url));
214
+ if (target.protocol !== "http:" || target.username || target.password)
215
+ throw new Error("invalid proxied URL");
216
+ const route = await resolveRoute(target);
217
+ if (route.proxyUri)
218
+ requestThroughUpstream(route.proxyUri, target, route, req, res, countBytes);
219
+ else
220
+ requestDirect(target, route, req, res, countBytes);
221
+ }
222
+ catch {
223
+ res.writeHead(502);
224
+ res.end("render destination blocked");
225
+ }
226
+ });
227
+ server.on("connection", (socket) => {
228
+ sockets.add(socket);
229
+ socket.once("close", () => sockets.delete(socket));
230
+ });
231
+ server.on("clientError", (_error, socket) => socket.destroy());
232
+ server.on("connect", async (req, client, head) => {
233
+ client.on("error", () => { });
234
+ if (!countRequest()) {
235
+ client.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
236
+ return;
237
+ }
238
+ try {
239
+ const target = parseConnectTarget(String(req.url));
240
+ const port = Number(target.port || 443);
241
+ const route = await resolveRoute(target);
242
+ const authority = pinnedAuthority(route, port);
243
+ let tunnel;
244
+ if (route.proxyUri) {
245
+ tunnel = await upstreamTunnel(route.proxyUri, authority, (socket) => {
246
+ sockets.add(socket);
247
+ socket.once("close", () => sockets.delete(socket));
248
+ });
249
+ }
250
+ else {
251
+ const socket = createConnection({ host: route.address, port, family: route.family });
252
+ sockets.add(socket);
253
+ socket.once("close", () => sockets.delete(socket));
254
+ socket.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => socket.destroy(new Error("render destination timeout")));
255
+ await once(socket, "connect");
256
+ socket.setTimeout(BROWSER_TIMEOUT_MS, () => socket.destroy());
257
+ tunnel = { socket, leftover: Buffer.alloc(0) };
258
+ }
259
+ const remote = tunnel.socket;
260
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
261
+ if (tunnel.leftover.length)
262
+ client.write(tunnel.leftover);
263
+ if (head.length)
264
+ remote.write(head);
265
+ // Browsers routinely cancel speculative/subresource tunnels. A pipe destination can then raise EPIPE;
266
+ // pair the two endpoints explicitly so cancellation is ordinary cleanup, never a Hara process crash.
267
+ client.on("error", () => remote.destroy());
268
+ remote.on("error", () => client.destroy());
269
+ client.once("close", () => remote.destroy());
270
+ remote.once("close", () => client.destroy());
271
+ remote.on("data", (chunk) => {
272
+ if (!countBytes(chunk.length)) {
273
+ remote.destroy();
274
+ client.destroy();
275
+ }
276
+ });
277
+ client.on("data", (chunk) => {
278
+ if (!countBytes(chunk.length)) {
279
+ remote.destroy();
280
+ client.destroy();
281
+ }
282
+ });
283
+ client.pipe(remote);
284
+ remote.pipe(client);
285
+ }
286
+ catch {
287
+ client.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
288
+ }
289
+ });
290
+ server.listen(0, "127.0.0.1");
291
+ await once(server, "listening");
292
+ const address = server.address();
293
+ if (!address || typeof address === "string")
294
+ throw new Error("could not bind render proxy");
295
+ return {
296
+ url: `http://127.0.0.1:${address.port}`,
297
+ async close() {
298
+ if (closed)
299
+ return;
300
+ closed = true;
301
+ for (const socket of sockets)
302
+ socket.destroy();
303
+ await new Promise((resolve) => server.close(() => resolve()));
304
+ },
305
+ };
306
+ }
307
+ /** Render one page in an isolated profile. This is deliberately optional: callers classify it as computer
308
+ * use so ordinary read-only web_fetch never starts a JS engine without a user-approved render:true call. */
309
+ export async function renderHeadlessHtml(url, resolveRoute, parentSignal, env = process.env) {
310
+ const browser = findHeadlessBrowser(env);
311
+ if (!browser)
312
+ return { error: "browser-unavailable" };
313
+ if (parentSignal?.aborted)
314
+ return { error: "failed" };
315
+ const profile = mkdtempSync(join(tmpdir(), "hara-headless-web-"));
316
+ let proxy;
317
+ let child;
318
+ let timer;
319
+ let cancelTree;
320
+ try {
321
+ // Keep proxy startup inside the cleanup boundary: a bind/listen failure must not strand the fresh
322
+ // browser profile created above.
323
+ proxy = await startValidatingProxy(resolveRoute);
324
+ const args = [
325
+ "--headless=new",
326
+ "--disable-background-networking",
327
+ "--disable-component-update",
328
+ "--disable-default-apps",
329
+ "--disable-extensions",
330
+ "--disable-sync",
331
+ "--disable-translate",
332
+ "--disable-quic",
333
+ "--disable-dev-shm-usage",
334
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
335
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE ::1",
336
+ "--metrics-recording-only",
337
+ "--mute-audio",
338
+ "--no-default-browser-check",
339
+ "--no-first-run",
340
+ "--password-store=basic",
341
+ "--use-mock-keychain",
342
+ `--user-data-dir=${profile}`,
343
+ `--disk-cache-dir=${join(profile, "cache")}`,
344
+ `--proxy-server=${proxy.url}`,
345
+ "--proxy-bypass-list=<-loopback>",
346
+ "--virtual-time-budget=8000",
347
+ "--dump-dom",
348
+ url.href,
349
+ ];
350
+ const processGroup = platform() !== "win32";
351
+ child = spawn(browser, args, {
352
+ stdio: ["ignore", "pipe", "pipe"],
353
+ detached: processGroup,
354
+ env: toolSubprocessEnv(env, {
355
+ HARA_BROWSER_PATH: undefined,
356
+ HARA_WEB_PROXY: undefined,
357
+ HTTPS_PROXY: undefined,
358
+ HTTP_PROXY: undefined,
359
+ https_proxy: undefined,
360
+ http_proxy: undefined,
361
+ }),
362
+ });
363
+ const result = await new Promise((resolve) => {
364
+ const chunks = [];
365
+ let size = 0;
366
+ let settled = false;
367
+ const finish = (value) => {
368
+ if (settled)
369
+ return;
370
+ settled = true;
371
+ if (timer)
372
+ clearTimeout(timer);
373
+ parentSignal?.removeEventListener("abort", abort);
374
+ resolve(value);
375
+ };
376
+ const stop = (value) => {
377
+ if (child && !cancelTree)
378
+ cancelTree = terminateSubprocessTree(child, { processGroup, force: true });
379
+ finish(value);
380
+ };
381
+ const abort = () => stop({ error: "failed" });
382
+ parentSignal?.addEventListener("abort", abort, { once: true });
383
+ timer = setTimeout(() => stop({ error: "timed-out" }), BROWSER_TIMEOUT_MS);
384
+ child.stdout.on("data", (chunk) => {
385
+ size += chunk.length;
386
+ if (size > MAX_BROWSER_HTML_BYTES)
387
+ return stop({ error: "output-too-large" });
388
+ chunks.push(chunk);
389
+ // Chrome can keep utility processes/pipes alive briefly after --dump-dom has emitted the complete
390
+ // document (especially with a validating proxy). The serialized closing tag is the result boundary;
391
+ // return it immediately and terminate the isolated process tree instead of false-timing-out.
392
+ const tail = Buffer.concat(chunks.slice(-3)).toString("utf8").slice(-256);
393
+ if (/<\/html>\s*$/iu.test(tail)) {
394
+ if (child && !cancelTree)
395
+ cancelTree = terminateSubprocessTree(child, { processGroup, force: true });
396
+ finish({ html: Buffer.concat(chunks).toString("utf8") });
397
+ }
398
+ });
399
+ // Browser diagnostics can contain machine-specific paths. Drain but never return them to the model.
400
+ child.stderr.resume();
401
+ child.once("error", () => finish({ error: "failed" }));
402
+ child.once("close", (code) => {
403
+ if (code !== 0 || !chunks.length)
404
+ return finish({ error: "failed" });
405
+ finish({ html: Buffer.concat(chunks).toString("utf8") });
406
+ });
407
+ });
408
+ return result;
409
+ }
410
+ finally {
411
+ if (timer)
412
+ clearTimeout(timer);
413
+ cancelTree?.();
414
+ await proxy?.close().catch(() => { });
415
+ rmSync(profile, { recursive: true, force: true });
416
+ }
417
+ }
@@ -12,6 +12,18 @@ const MAX_AUTO_PROJECT_CANDIDATES = 80;
12
12
  const MAX_AUTO_PROJECT_CHARS = 8_000_000;
13
13
  const MAX_MESSAGE_SCAN_CHARS = 120_000;
14
14
  const MAX_EXCERPT_CHARS = 700;
15
+ const HISTORICAL_REFERENCE = [
16
+ /(?:之前|以前|上次|前一(?:次|个)|此前|我们前面)(?:的|聊过|讨论过|说过|做过|处理过|提过|那个|那次|任务|问题|方案|对话|会话|项目|内容)?/u,
17
+ /(?:继续|接着)(?:之前|以前|上次|前一(?:次|个)|此前)(?:的|那个|那次)?/u,
18
+ /(?:还记得|记不记得|你记得)(?:之前|以前|上次|我们)?/u,
19
+ /\b(?:previous|prior|earlier|last)\s+(?:chat|session|conversation|time|task|issue|discussion|project)\b/iu,
20
+ /\b(?:continue|pick up)\s+(?:from|where)\b/iu,
21
+ /\b(?:do you remember|we (?:discussed|talked about|worked on)|you said before)\b/iu,
22
+ ];
23
+ const HISTORICAL_REFERENCE_NEGATION = [
24
+ /(?:不要|不用|无需|别)(?:查|找|看|搜索|读取|回忆).{0,8}(?:之前|以前|上次|历史|旧会话)/u,
25
+ /\b(?:do not|don't|dont|no need to)\s+(?:search|read|look at|recall).{0,32}(?:previous|prior|earlier|history|old (?:chat|session))/iu,
26
+ ];
15
27
  function normalized(value) {
16
28
  return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
17
29
  }
@@ -23,6 +35,28 @@ export function sessionSearchTerms(query) {
23
35
  const terms = lexicalSearchTerms(query);
24
36
  return terms.length <= 32 ? terms : [...terms.slice(0, 16), ...terms.slice(-16)];
25
37
  }
38
+ /** Only search raw transcripts when the user explicitly points at older work. This makes ordinary turns
39
+ * deterministic and cheap while fixing the common "continue the task from our previous chat" case without
40
+ * relying on the model to notice and call session_search itself. Explicit opt-outs always win. */
41
+ export function sessionRecallQuery(messageValue) {
42
+ const message = String(messageValue ?? "").replace(/\s+/g, " ").trim();
43
+ if (!message || HISTORICAL_REFERENCE_NEGATION.some((pattern) => pattern.test(message)))
44
+ return null;
45
+ if (!HISTORICAL_REFERENCE.some((pattern) => pattern.test(message)))
46
+ return null;
47
+ return message.slice(0, 512);
48
+ }
49
+ /** Return an injectable, clearly untrusted recall block, or an empty string when no explicit cue/match
50
+ * exists. Audience/project enforcement remains centralized in searchSessionHistory. */
51
+ export async function automaticSessionRecall(message, ctx) {
52
+ const query = sessionRecallQuery(message);
53
+ if (!query)
54
+ return "";
55
+ const result = await searchSessionHistory(query, "auto", 3, ctx);
56
+ if (result === "(no session matches)" || result.startsWith("Error:") || result.startsWith("Blocked:"))
57
+ return "";
58
+ return `Automatic prior-session recall (triggered by the user's explicit historical reference):\n${result}`;
59
+ }
26
60
  function visibleMessages(history) {
27
61
  const out = [];
28
62
  for (const message of history) {
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.4",
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
  }