@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 +33 -0
- package/README.md +22 -1
- package/dist/agent/loop.js +15 -2
- package/dist/config.js +4 -2
- package/dist/context/agents-md.js +18 -1
- package/dist/exec/jobs.js +2 -2
- package/dist/index.js +76 -9
- package/dist/package-registry.js +43 -0
- package/dist/tools/all.js +1 -1
- package/dist/tools/builtin.js +94 -5
- package/dist/tools/headless-web.js +417 -0
- package/dist/tools/session-search.js +34 -0
- package/dist/tools/web.js +41 -4
- package/dist/tui/InputBox.js +8 -3
- package/package.json +2 -2
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),
|
|
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
|
package/dist/agent/loop.js
CHANGED
|
@@ -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.
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/tools/builtin.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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).
|
|
703
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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)}`;
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -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
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
84
|
+
"fast-uri": "3.1.4"
|
|
85
85
|
}
|
|
86
86
|
}
|