@nanhara/hara 0.121.0 → 0.121.1
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 +19 -0
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +24 -2
- package/dist/config.js +31 -5
- package/dist/feedback.js +2 -7
- package/dist/index.js +15 -3
- package/dist/security/secrets.js +75 -0
- package/dist/serve/server.js +5 -5
- package/dist/serve/sessions.js +5 -1
- package/dist/session/store.js +58 -16
- package/dist/tools/builtin.js +40 -4
- package/dist/tools/web.js +227 -41
- package/dist/tui/run.js +26 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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.121.1 — field-feedback reliability & credential safety
|
|
9
|
+
|
|
10
|
+
- **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
|
|
11
|
+
width change; height-only window drags keep the input box visible and width changes repaint immediately.
|
|
12
|
+
- **Credentials stay out of durable transcripts and generated code.** Session writes deeply redact likely
|
|
13
|
+
keys/tokens/passwords (including tool inputs/results); legacy content is redacted when loaded and migrated
|
|
14
|
+
on its next atomic, private save. Interactive pastes get a warning without echoing the value. The agent must use environment references,
|
|
15
|
+
never literal secrets in code/commands, and may only populate a real-secret `.env` when explicitly asked.
|
|
16
|
+
- **`web_search` has a verified mainland path.** A configured Tavily request races the keyless Bing China
|
|
17
|
+
HTML path; Baidu, Google, and DuckDuckGo remain concurrent secondary fallbacks. Provider timeouts are
|
|
18
|
+
isolated, and JavaScript-only `web_fetch` pages now explain that a browser/API/connector is required
|
|
19
|
+
instead of returning a misleading empty body.
|
|
20
|
+
- **Long setup commands fail usefully.** Package installs automatically become background jobs unless the
|
|
21
|
+
caller explicitly sets timeout/background, while ngrok tunnels preflight local authentication once and
|
|
22
|
+
stop with a focused fix instead of cycling through unrelated tunnel tools.
|
|
23
|
+
- **Persistent desktop sessions re-read configuration.** New/resumed `hara serve` sessions pick up rotated
|
|
24
|
+
`apiKey`/model/baseURL values without a restart; empty env/project values no longer mask valid global
|
|
25
|
+
config, and a 401 says the configured credential expired rather than asking users to paste it into chat.
|
|
26
|
+
|
|
8
27
|
## 0.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
|
|
9
28
|
|
|
10
29
|
- **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
|
package/dist/agent/failover.js
CHANGED
|
@@ -38,7 +38,7 @@ export function failoverAction(kind, s) {
|
|
|
38
38
|
export function errorHint(kind) {
|
|
39
39
|
switch (kind) {
|
|
40
40
|
case "auth":
|
|
41
|
-
return " —
|
|
41
|
+
return " — the configured credential was rejected or expired; update ~/.hara/config.json, the active profile, or its environment variable, then retry. Do not paste the key into chat";
|
|
42
42
|
case "rate_limit":
|
|
43
43
|
return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
|
|
44
44
|
case "overloaded":
|
package/dist/agent/loop.js
CHANGED
|
@@ -16,6 +16,7 @@ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_ST
|
|
|
16
16
|
import { setTurnPhase } from "./phase.js";
|
|
17
17
|
import { recordTouch } from "./touched.js";
|
|
18
18
|
import { resolve as resolvePath } from "node:path";
|
|
19
|
+
import { redactSensitiveText } from "../security/secrets.js";
|
|
19
20
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
20
21
|
const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
21
22
|
/** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
|
|
@@ -65,7 +66,14 @@ re-reading a big file after every edit is the slowest habit an agent can have.
|
|
|
65
66
|
When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
|
|
66
67
|
change something (arguments / approach / tool) before trying again. After two failed variants of the same
|
|
67
68
|
approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
|
|
68
|
-
the errors said. Repeating a failed action hoping for a different result is how sessions die.
|
|
69
|
+
the errors said. Repeating a failed action hoping for a different result is how sessions die.
|
|
70
|
+
Never put a literal password, API key, token, App Secret, Authorization header, or other credential in a
|
|
71
|
+
source file or shell command. Reference an environment variable instead (for example process.env.API_KEY or
|
|
72
|
+
$API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
|
|
73
|
+
.env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
|
|
74
|
+
echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
|
|
75
|
+
not make embedding credentials acceptable.
|
|
76
|
+
For broad,
|
|
69
77
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
70
78
|
independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
|
|
71
79
|
mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
|
|
@@ -88,7 +96,10 @@ site, build output), check they are newer than their sources (compare mtimes or
|
|
|
88
96
|
the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
|
|
89
97
|
When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
|
|
90
98
|
that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
|
|
91
|
-
two-day-old work.
|
|
99
|
+
two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
|
|
100
|
+
poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
|
|
101
|
+
verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
|
|
102
|
+
of unrelated tunnel tools. After completing a task, give a one-line summary.`;
|
|
92
103
|
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
93
104
|
* (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
|
|
94
105
|
function gatewayNote() {
|
|
@@ -118,6 +129,17 @@ export async function runAgent(history, opts) {
|
|
|
118
129
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
119
130
|
let triedFallback = false;
|
|
120
131
|
let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
|
|
132
|
+
// Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
|
|
133
|
+
// transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
|
|
134
|
+
const latestUser = [...history].reverse().find((m) => m.role === "user");
|
|
135
|
+
const sensitive = latestUser?.role === "user" ? redactSensitiveText(latestUser.content).redactions : [];
|
|
136
|
+
if (sensitive.length && !opts.quiet) {
|
|
137
|
+
const note = "⚠ possible credential detected — the saved session copy will be redacted; prefer passing secrets through environment variables.";
|
|
138
|
+
if (ctx.ui)
|
|
139
|
+
ctx.ui.notice(note);
|
|
140
|
+
else if (stdout.isTTY)
|
|
141
|
+
out(c.yellow(note + "\n"));
|
|
142
|
+
}
|
|
121
143
|
// Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
|
|
122
144
|
// nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
|
|
123
145
|
// non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
|
package/dist/config.js
CHANGED
|
@@ -49,6 +49,28 @@ export function readRawConfig() {
|
|
|
49
49
|
return {};
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
const ROUTING_CONFIG_KEYS = new Set(["provider", "apiKey", "model", "baseURL"]);
|
|
53
|
+
/** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
|
|
54
|
+
* an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
|
|
55
|
+
function withoutBlankRoutingValues(input) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [key, value] of Object.entries(input)) {
|
|
58
|
+
if (ROUTING_CONFIG_KEYS.has(key) && typeof value === "string") {
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (!trimmed)
|
|
61
|
+
continue;
|
|
62
|
+
out[key] = trimmed;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
out[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function nonBlankEnv(value) {
|
|
71
|
+
const trimmed = value?.trim();
|
|
72
|
+
return trimmed || undefined;
|
|
73
|
+
}
|
|
52
74
|
/** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
|
|
53
75
|
function readProjectConfig(cwd) {
|
|
54
76
|
let dir = resolve(cwd);
|
|
@@ -121,12 +143,16 @@ export function loadConfig(opts = {}) {
|
|
|
121
143
|
const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
|
|
122
144
|
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
123
145
|
const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
|
|
124
|
-
const merged = {
|
|
125
|
-
|
|
146
|
+
const merged = {
|
|
147
|
+
...withoutBlankRoutingValues(globalBase),
|
|
148
|
+
...withoutBlankRoutingValues(project),
|
|
149
|
+
...withoutBlankRoutingValues(overlay),
|
|
150
|
+
};
|
|
151
|
+
const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
|
|
126
152
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
127
|
-
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
128
|
-
const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
|
|
129
|
-
const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
|
|
153
|
+
const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
|
|
154
|
+
const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
|
|
155
|
+
const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
|
|
130
156
|
const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
|
|
131
157
|
const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
|
|
132
158
|
const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
|
package/dist/feedback.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
|
|
4
4
|
// The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
|
|
5
5
|
import { platform, release, arch } from "node:os";
|
|
6
|
+
import { redactSensitiveText } from "./security/secrets.js";
|
|
6
7
|
export function collectEnv(version, model) {
|
|
7
8
|
return {
|
|
8
9
|
version,
|
|
@@ -14,13 +15,7 @@ export function collectEnv(version, model) {
|
|
|
14
15
|
/** Strip credential-looking material from text that is about to become a PUBLIC issue.
|
|
15
16
|
* Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
|
|
16
17
|
export function redact(text) {
|
|
17
|
-
return text
|
|
18
|
-
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
|
|
19
|
-
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
|
|
20
|
-
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
|
|
21
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
|
|
22
|
-
.replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
|
|
23
|
-
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
|
|
18
|
+
return redactSensitiveText(text).text;
|
|
24
19
|
}
|
|
25
20
|
/** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
|
|
26
21
|
* and command-filed issues read identically to the triage side. */
|
package/dist/index.js
CHANGED
|
@@ -1574,9 +1574,21 @@ program
|
|
|
1574
1574
|
version: pkg.version,
|
|
1575
1575
|
providerId: cfg.provider,
|
|
1576
1576
|
model: cfg.model,
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1577
|
+
// `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
|
|
1578
|
+
// new/resumed session and model operation so a repaired/rotated key takes effect without restarting
|
|
1579
|
+
// the desktop server (and, critically, never ask for a key that is already on disk).
|
|
1580
|
+
buildSessionProvider: async () => {
|
|
1581
|
+
const live = loadConfig();
|
|
1582
|
+
return withRouting(await buildProvider(live), live);
|
|
1583
|
+
},
|
|
1584
|
+
buildProviderFor: async (model, effort) => {
|
|
1585
|
+
const live = loadConfig();
|
|
1586
|
+
return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
|
|
1587
|
+
},
|
|
1588
|
+
listModels: () => {
|
|
1589
|
+
const live = loadConfig();
|
|
1590
|
+
return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
|
|
1591
|
+
},
|
|
1580
1592
|
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1581
1593
|
spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
|
|
1582
1594
|
guardian: guardianOpt,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Shared secret redaction for anything that may leave the live model context (session JSON, public
|
|
2
|
+
* feedback, logs). Deliberately conservative: a false positive hides a value in a local transcript;
|
|
3
|
+
* a false negative leaves a credential on disk. */
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
label: "private-key",
|
|
7
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
8
|
+
replace: "<REDACTED:private-key>",
|
|
9
|
+
},
|
|
10
|
+
{ label: "sk-key", re: /\bsk-[A-Za-z0-9_-]{8,}\b/g, replace: "sk-***" },
|
|
11
|
+
{ label: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: "gh*_***" },
|
|
12
|
+
{ label: "aws-key", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, replace: "AWS-KEY-***" },
|
|
13
|
+
{ label: "jwt", re: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: "JWT-***" },
|
|
14
|
+
{
|
|
15
|
+
label: "bearer-token",
|
|
16
|
+
re: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
17
|
+
replace: "Bearer ***",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
// Generic all-caps environment variables such as OPENAI_KEY / SOME_SERVICE_PRIVATE_KEY. Keep this
|
|
21
|
+
// case-sensitive so ordinary prose/code identifiers ending in "key" are not over-redacted.
|
|
22
|
+
label: "environment-credential",
|
|
23
|
+
re: /(\b[A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASSWD)\b\s*=\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/g,
|
|
24
|
+
replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
// FEISHU_APP_SECRET=… · apiKey: "…" · "access_token": "…". The optional quote immediately
|
|
28
|
+
// after the key handles JSON without consuming the opening quote of the value.
|
|
29
|
+
label: "credential-assignment",
|
|
30
|
+
re: /(\b(?:[A-Za-z][A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*|(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*)\b["']?\s*[:=]\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
|
|
31
|
+
replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: "authorization",
|
|
35
|
+
re: /(\bAuthorization\s*[:=]\s*)(?!Bearer\s+)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
|
|
36
|
+
replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "credential-flag",
|
|
40
|
+
re: /(--?(?:api[-_]?key|token|secret|password|passwd)(?:\s+|=))(["']?)([^\s"']{6,})(["']?)/gi,
|
|
41
|
+
replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
export function redactSensitiveText(text) {
|
|
45
|
+
let out = text;
|
|
46
|
+
const redactions = [];
|
|
47
|
+
for (const pattern of PATTERNS) {
|
|
48
|
+
pattern.re.lastIndex = 0;
|
|
49
|
+
out = out.replace(pattern.re, (...args) => {
|
|
50
|
+
redactions.push(pattern.label);
|
|
51
|
+
return typeof pattern.replace === "string" ? pattern.replace : pattern.replace(...args);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { text: out, redactions };
|
|
55
|
+
}
|
|
56
|
+
/** Deep-copy a JSON-shaped value while redacting every string. Session history contains secrets not only
|
|
57
|
+
* in user text but also in assistant tool inputs and tool results, so a top-level content-only pass is
|
|
58
|
+
* insufficient. The live value is never mutated. */
|
|
59
|
+
export function redactSensitiveValue(value) {
|
|
60
|
+
const hits = [];
|
|
61
|
+
const walk = (v) => {
|
|
62
|
+
if (typeof v === "string") {
|
|
63
|
+
const r = redactSensitiveText(v);
|
|
64
|
+
hits.push(...r.redactions);
|
|
65
|
+
return r.text;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(v))
|
|
68
|
+
return v.map(walk);
|
|
69
|
+
if (v && typeof v === "object") {
|
|
70
|
+
return Object.fromEntries(Object.entries(v).map(([k, child]) => [k, walk(child)]));
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
};
|
|
74
|
+
return { value: walk(value), redactions: hits };
|
|
75
|
+
}
|
package/dist/serve/server.js
CHANGED
|
@@ -223,10 +223,10 @@ export async function startServe(opts, deps) {
|
|
|
223
223
|
case "session.create": {
|
|
224
224
|
const provider = await deps.buildSessionProvider();
|
|
225
225
|
if (!provider)
|
|
226
|
-
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated —
|
|
226
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
227
227
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
228
228
|
const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
|
|
229
|
-
const s = hub.create({ cwd, provider, providerId:
|
|
229
|
+
const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
|
|
230
230
|
return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
|
|
231
231
|
}
|
|
232
232
|
case "session.resume": {
|
|
@@ -234,7 +234,7 @@ export async function startServe(opts, deps) {
|
|
|
234
234
|
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
235
235
|
const provider = await deps.buildSessionProvider();
|
|
236
236
|
if (!provider)
|
|
237
|
-
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated —
|
|
237
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
238
238
|
const r = hub.resume(p.sessionId, { provider, approval: deps.approval, projectContext: undefined });
|
|
239
239
|
if ("missing" in r)
|
|
240
240
|
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
@@ -305,8 +305,8 @@ export async function startServe(opts, deps) {
|
|
|
305
305
|
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
306
306
|
const provider = await deps.buildSessionProvider();
|
|
307
307
|
if (!provider)
|
|
308
|
-
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated —
|
|
309
|
-
const r = hub.fork(p.sessionId, { provider, providerId:
|
|
308
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
|
|
309
|
+
const r = hub.fork(p.sessionId, { provider, providerId: provider.id, approval: deps.approval, projectContext: undefined });
|
|
310
310
|
if ("missing" in r)
|
|
311
311
|
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
312
312
|
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
package/dist/serve/sessions.js
CHANGED
|
@@ -42,6 +42,10 @@ export class SessionHub {
|
|
|
42
42
|
if (!lock.ok)
|
|
43
43
|
return { lockedBy: lock.pid ?? 0 };
|
|
44
44
|
const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
|
|
45
|
+
// A rotated config may change the provider/model between runs. The resumed transcript is preserved,
|
|
46
|
+
// while new turns and subsequent persistence accurately reflect the live route.
|
|
47
|
+
s.meta.provider = o.provider.id;
|
|
48
|
+
s.meta.model = o.provider.model;
|
|
45
49
|
this.sessions.set(id, s);
|
|
46
50
|
return { session: s };
|
|
47
51
|
}
|
|
@@ -101,7 +105,7 @@ export class SessionHub {
|
|
|
101
105
|
id: newSessionId(),
|
|
102
106
|
cwd: src.meta.cwd,
|
|
103
107
|
provider: o.providerId,
|
|
104
|
-
model:
|
|
108
|
+
model: o.provider.model,
|
|
105
109
|
title: src.meta.title ? `${src.meta.title} ⑂` : "",
|
|
106
110
|
createdAt: new Date().toISOString(),
|
|
107
111
|
updatedAt: "",
|
package/dist/session/store.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { redactSensitiveValue } from "../security/secrets.js";
|
|
6
7
|
/** Derive the session source from the spawn environment — the gateway subprocess runs with
|
|
7
8
|
* HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
|
|
8
9
|
export function sessionSourceFromEnv() {
|
|
@@ -20,7 +21,8 @@ export function automatedTitle(source, sourceName, at = new Date()) {
|
|
|
20
21
|
}
|
|
21
22
|
function sessionsDir() {
|
|
22
23
|
const d = join(homedir(), ".hara", "sessions");
|
|
23
|
-
mkdirSync(d, { recursive: true });
|
|
24
|
+
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
25
|
+
chmodSync(d, 0o700); // tighten legacy installs too; session transcripts are private
|
|
24
26
|
return d;
|
|
25
27
|
}
|
|
26
28
|
const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
|
|
@@ -157,39 +159,79 @@ export function slugify(text, max = 40) {
|
|
|
157
159
|
export function saveSession(meta, history) {
|
|
158
160
|
meta.updatedAt = new Date().toISOString();
|
|
159
161
|
const data = { meta, history };
|
|
160
|
-
|
|
162
|
+
// Redact a deep COPY: the live turn may still need a credential the user supplied, but the durable
|
|
163
|
+
// transcript never should. Tool inputs/results are included, not just user message content.
|
|
164
|
+
const safe = redactSensitiveValue(data).value;
|
|
165
|
+
// Keep structural routing/identity fields stable even if a path/model happens to resemble a secret.
|
|
166
|
+
safe.meta.id = meta.id;
|
|
167
|
+
safe.meta.cwd = meta.cwd;
|
|
168
|
+
safe.meta.provider = meta.provider;
|
|
169
|
+
safe.meta.model = meta.model;
|
|
170
|
+
safe.meta.createdAt = meta.createdAt;
|
|
171
|
+
safe.meta.updatedAt = meta.updatedAt;
|
|
172
|
+
const target = sessionFile(meta.id);
|
|
173
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
|
+
let fd;
|
|
175
|
+
try {
|
|
176
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
177
|
+
writeFileSync(fd, JSON.stringify(safe, null, 2), "utf8");
|
|
178
|
+
fsyncSync(fd);
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
fd = undefined;
|
|
181
|
+
renameSync(tmp, target); // same filesystem: readers see the complete old or complete new JSON
|
|
182
|
+
chmodSync(target, 0o600);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (fd !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* preserve the original error */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
rmSync(tmp, { force: true });
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* preserve the original error */
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
161
201
|
}
|
|
162
202
|
/** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
|
|
163
203
|
function isSessionData(d) {
|
|
164
204
|
const o = d;
|
|
165
205
|
return !!o && typeof o === "object" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
|
|
166
206
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return null;
|
|
207
|
+
/** Parse a legacy session and redact the in-memory copy. Reads stay read-only (no unlocked migration race);
|
|
208
|
+
* the next explicit save atomically replaces the old file with its private, redacted form. */
|
|
209
|
+
function readSessionFile(p) {
|
|
171
210
|
try {
|
|
172
211
|
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
173
|
-
|
|
212
|
+
if (!isSessionData(d))
|
|
213
|
+
return null;
|
|
214
|
+
return redactSensitiveValue(d).value;
|
|
174
215
|
}
|
|
175
216
|
catch {
|
|
176
217
|
return null;
|
|
177
218
|
}
|
|
178
219
|
}
|
|
220
|
+
export function loadSession(id) {
|
|
221
|
+
const p = sessionFile(id);
|
|
222
|
+
if (!existsSync(p))
|
|
223
|
+
return null;
|
|
224
|
+
return readSessionFile(p); // a corrupt / hand-edited file resumes as "no session" instead of crashing
|
|
225
|
+
}
|
|
179
226
|
/** Session metas, newest first; optionally filtered to a cwd. */
|
|
180
227
|
export function listSessions(cwd) {
|
|
181
228
|
let metas = [];
|
|
182
229
|
for (const f of readdirSync(sessionsDir())) {
|
|
183
230
|
if (!f.endsWith(".json"))
|
|
184
231
|
continue;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
metas.push(d.meta); // skip metaless/corrupt
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
/* skip corrupt */
|
|
192
|
-
}
|
|
232
|
+
const d = readSessionFile(join(sessionsDir(), f));
|
|
233
|
+
if (d?.meta.id && d.meta.updatedAt)
|
|
234
|
+
metas.push(d.meta); // skip metaless/corrupt; never mutate while listing
|
|
193
235
|
}
|
|
194
236
|
if (cwd)
|
|
195
237
|
metas = metas.filter((m) => m.cwd === cwd);
|
package/dist/tools/builtin.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
2
4
|
import { resolve, isAbsolute } from "node:path";
|
|
3
5
|
import { stdout as procOut } from "node:process";
|
|
4
6
|
import { registerTool } from "./registry.js";
|
|
@@ -12,6 +14,35 @@ import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
|
12
14
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
13
15
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
14
16
|
const MAX = 100_000;
|
|
17
|
+
/** Package installs are network-bound and routinely exceed the foreground cap. When the model omits an
|
|
18
|
+
* explicit foreground/background choice, detach these commands into the existing job system so the UI
|
|
19
|
+
* remains responsive and the agent can poll output instead of waiting five minutes for a hard kill. */
|
|
20
|
+
export function isPackageInstallCommand(command) {
|
|
21
|
+
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());
|
|
22
|
+
}
|
|
23
|
+
export function isNgrokTunnelCommand(command) {
|
|
24
|
+
return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
|
|
25
|
+
}
|
|
26
|
+
/** Read only the presence of ngrok auth — never return or print its value. */
|
|
27
|
+
export function ngrokAuthConfigured(env = process.env, home = homedir()) {
|
|
28
|
+
if (env.NGROK_AUTHTOKEN || env.NGROK_API_KEY)
|
|
29
|
+
return true;
|
|
30
|
+
const files = [
|
|
31
|
+
resolve(home, ".config/ngrok/ngrok.yml"),
|
|
32
|
+
resolve(home, "Library/Application Support/ngrok/ngrok.yml"),
|
|
33
|
+
resolve(home, ".ngrok2/ngrok.yml"),
|
|
34
|
+
];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(file) && /^\s*(?:authtoken|api_key)\s*:\s*\S+/im.test(readFileSync(file, "utf8")))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* unreadable config counts as unconfigured */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
15
46
|
/** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
|
|
16
47
|
* repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
|
|
17
48
|
* called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
|
|
@@ -150,16 +181,21 @@ registerTool({
|
|
|
150
181
|
type: "object",
|
|
151
182
|
properties: {
|
|
152
183
|
command: { type: "string" },
|
|
153
|
-
timeout_ms: { type: "number", description: "default 300000 (5 min);
|
|
154
|
-
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task)
|
|
184
|
+
timeout_ms: { type: "number", description: "default 300000 (5 min); set explicitly for a foreground long build/transform" },
|
|
185
|
+
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task). Package installs auto-background when this field and timeout_ms are omitted. Poll with the `job` tool before depending on completion." },
|
|
155
186
|
},
|
|
156
187
|
required: ["command"],
|
|
157
188
|
},
|
|
158
189
|
kind: "exec",
|
|
159
190
|
async run(input, ctx) {
|
|
160
|
-
if (input.
|
|
191
|
+
if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
|
|
192
|
+
return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
|
|
193
|
+
"Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
|
|
194
|
+
}
|
|
195
|
+
const autoPackageJob = input.background === undefined && input.timeout_ms === undefined && isPackageInstallCommand(input.command);
|
|
196
|
+
if (input.background || autoPackageJob) {
|
|
161
197
|
const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
|
|
162
|
-
return
|
|
198
|
+
return `${autoPackageJob ? "Package install auto-started" : "Started"} background job ${id}: \`${input.command}\`. 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 installed packages.`;
|
|
163
199
|
}
|
|
164
200
|
// Network fault tolerance — short-circuit if this command targets a host already found unreachable this
|
|
165
201
|
// session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
|
package/dist/tools/web.js
CHANGED
|
@@ -7,6 +7,8 @@ import { lookup } from "node:dns/promises";
|
|
|
7
7
|
import { isIP } from "node:net";
|
|
8
8
|
import { wrapUntrusted } from "../security/external-content.js";
|
|
9
9
|
const MAX = 60_000;
|
|
10
|
+
const SEARCH_ATTEMPT_MS = 8_000;
|
|
11
|
+
const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
|
|
10
12
|
/** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
|
|
11
13
|
export function isPrivateIp(ip) {
|
|
12
14
|
const host = ip.replace(/^\[|\]$/g, "");
|
|
@@ -115,6 +117,146 @@ export function parseSearchResults(html, limit) {
|
|
|
115
117
|
}
|
|
116
118
|
return out;
|
|
117
119
|
}
|
|
120
|
+
/** Parse Baidu's server-rendered result headings. Baidu result links are redirects, which is fine:
|
|
121
|
+
* web_fetch follows redirects while re-running its SSRF check on every hop. This gives mainland users a
|
|
122
|
+
* keyless search path that does not depend on an overseas API being reachable. */
|
|
123
|
+
export function parseBaiduSearchResults(html, limit) {
|
|
124
|
+
const strip = (s) => s
|
|
125
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
126
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
127
|
+
.replace(/<[^>]+>/g, " ")
|
|
128
|
+
.replace(/ | /gi, " ")
|
|
129
|
+
.replace(/&/gi, "&")
|
|
130
|
+
.replace(/</gi, "<")
|
|
131
|
+
.replace(/>/gi, ">")
|
|
132
|
+
.replace(/"/gi, '"')
|
|
133
|
+
.replace(/'|'/gi, "'")
|
|
134
|
+
.replace(/\s+/g, " ")
|
|
135
|
+
.trim();
|
|
136
|
+
const headings = [];
|
|
137
|
+
const re = /<h3\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h3>/gi;
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = re.exec(html)) && headings.length < limit) {
|
|
140
|
+
const title = strip(m[3]);
|
|
141
|
+
const url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
142
|
+
if (title && /^https?:\/\//i.test(url))
|
|
143
|
+
headings.push({ at: m.index, end: re.lastIndex, title, url });
|
|
144
|
+
}
|
|
145
|
+
return headings.map((h, i) => {
|
|
146
|
+
// The abstract normally sits between this heading and the next result heading. Strip that bounded
|
|
147
|
+
// slice and cap it; if Baidu changes class names the title/link still survive.
|
|
148
|
+
const boundary = headings[i + 1]?.at ?? Math.min(html.length, h.end + 1800);
|
|
149
|
+
const snippet = strip(html.slice(h.end, boundary)).slice(0, 240);
|
|
150
|
+
return { title: h.title, url: h.url, snippet };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** Parse Bing's stable server-rendered result list (`cn.bing.com` is reachable in the mainland network
|
|
154
|
+
* where overseas agent-search APIs commonly fail). */
|
|
155
|
+
export function parseBingSearchResults(html, limit) {
|
|
156
|
+
const strip = (s) => s
|
|
157
|
+
.replace(/<[^>]+>/g, " ")
|
|
158
|
+
.replace(/ | /gi, " ")
|
|
159
|
+
.replace(/ | /gi, " ")
|
|
160
|
+
.replace(/ | /gi, " ")
|
|
161
|
+
.replace(/&/gi, "&")
|
|
162
|
+
.replace(/</gi, "<")
|
|
163
|
+
.replace(/>/gi, ">")
|
|
164
|
+
.replace(/"/gi, '"')
|
|
165
|
+
.replace(/'|'/gi, "'")
|
|
166
|
+
.replace(/\s+/g, " ")
|
|
167
|
+
.trim();
|
|
168
|
+
const out = [];
|
|
169
|
+
const blockRe = /<li\b[^>]*class=(?:"[^"]*\bb_algo\b[^"]*"|'[^']*\bb_algo\b[^']*')[^>]*>([\s\S]*?)<\/li>/gi;
|
|
170
|
+
let block;
|
|
171
|
+
while ((block = blockRe.exec(html)) && out.length < limit) {
|
|
172
|
+
const link = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i.exec(block[1]);
|
|
173
|
+
if (!link)
|
|
174
|
+
continue;
|
|
175
|
+
const url = (link[1] ?? link[2] ?? "").replace(/&/g, "&");
|
|
176
|
+
const title = strip(link[3]);
|
|
177
|
+
if (!title || !/^https?:\/\//i.test(url))
|
|
178
|
+
continue;
|
|
179
|
+
const paragraph = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block[1]);
|
|
180
|
+
out.push({ title, url, snippet: strip(paragraph?.[1] ?? "").slice(0, 240) });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Best-effort parser for Google's classic HTML result shape. Google often serves a redirect/challenge
|
|
185
|
+
* shell in mainland environments, so this is a fallback, not the sole search path. */
|
|
186
|
+
export function parseGoogleSearchResults(html, limit) {
|
|
187
|
+
const strip = (s) => s
|
|
188
|
+
.replace(/<[^>]+>/g, " ")
|
|
189
|
+
.replace(/&/gi, "&")
|
|
190
|
+
.replace(/</gi, "<")
|
|
191
|
+
.replace(/>/gi, ">")
|
|
192
|
+
.replace(/"/gi, '"')
|
|
193
|
+
.replace(/'|'/gi, "'")
|
|
194
|
+
.replace(/\s+/g, " ")
|
|
195
|
+
.trim();
|
|
196
|
+
const out = [];
|
|
197
|
+
const linkRe = /<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>[\s\S]{0,1600}?<h3\b[^>]*>([\s\S]*?)<\/h3>[\s\S]*?<\/a>/gi;
|
|
198
|
+
let m;
|
|
199
|
+
while ((m = linkRe.exec(html)) && out.length < limit) {
|
|
200
|
+
let url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
201
|
+
if (url.startsWith("/url?")) {
|
|
202
|
+
try {
|
|
203
|
+
url = new URL(url, "https://www.google.com").searchParams.get("q") ?? "";
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
url = "";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const title = strip(m[3]);
|
|
210
|
+
if (!title || !/^https?:\/\//i.test(url) || /(?:^|\.)google\.[^/]+\//i.test(url))
|
|
211
|
+
continue;
|
|
212
|
+
out.push({ title, url, snippet: "" });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
/** Detect the common HTML shell returned by SPAs (root element + scripts/loading text, no readable body).
|
|
217
|
+
* web_fetch intentionally does not execute arbitrary page JavaScript, so surfacing the limitation is
|
|
218
|
+
* better than returning a misleading successful "(empty body)". */
|
|
219
|
+
export function looksLikeJsRenderedShell(html, readable) {
|
|
220
|
+
if (readable.trim().length >= 180)
|
|
221
|
+
return false;
|
|
222
|
+
const hasRoot = /<(?:div|main)\b[^>]*(?:id=["'](?:root|app|__next)["']|data-reactroot)/i.test(html);
|
|
223
|
+
const scripts = (html.match(/<script\b/gi) ?? []).length;
|
|
224
|
+
const shellText = /(?:enable javascript|javascript is required|loading[.…]*|正在加载|请启用\s*javascript)/i.test(readable || html);
|
|
225
|
+
return (hasRoot && scripts > 0) || (scripts >= 2 && readable.trim().length < 40) || shellText;
|
|
226
|
+
}
|
|
227
|
+
async function searchFetch(url, init) {
|
|
228
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
|
|
229
|
+
}
|
|
230
|
+
async function firstSuccessfulSearch(attempts, failures) {
|
|
231
|
+
if (!attempts.length)
|
|
232
|
+
return null;
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let remaining = attempts.length;
|
|
235
|
+
let settled = false;
|
|
236
|
+
for (const attempt of attempts) {
|
|
237
|
+
void attempt
|
|
238
|
+
.run()
|
|
239
|
+
.then((results) => {
|
|
240
|
+
if (results.length && !settled) {
|
|
241
|
+
settled = true;
|
|
242
|
+
resolve(results);
|
|
243
|
+
}
|
|
244
|
+
else if (!results.length) {
|
|
245
|
+
failures.push(`${attempt.name} no results`);
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
.catch((e) => {
|
|
249
|
+
const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
|
|
250
|
+
failures.push(`${attempt.name} ${reason}`);
|
|
251
|
+
})
|
|
252
|
+
.finally(() => {
|
|
253
|
+
remaining--;
|
|
254
|
+
if (remaining === 0 && !settled)
|
|
255
|
+
resolve(null);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
118
260
|
registerTool({
|
|
119
261
|
name: "web_search",
|
|
120
262
|
description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
|
|
@@ -135,51 +277,90 @@ registerTool({
|
|
|
135
277
|
return "(empty query)";
|
|
136
278
|
const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
|
|
137
279
|
const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
280
|
+
const failures = [];
|
|
281
|
+
// Race the configured agent-search API against a mainland-accessible HTML source. A blocked Tavily
|
|
282
|
+
// endpoint no longer adds an 8-second penalty before Hara starts the domestic fallback.
|
|
283
|
+
const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
|
|
284
|
+
const primaryAttempts = [];
|
|
285
|
+
if (key) {
|
|
286
|
+
primaryAttempts.push({
|
|
287
|
+
name: "Tavily",
|
|
288
|
+
run: async () => {
|
|
289
|
+
const res = await searchFetch("https://api.tavily.com/search", {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "content-type": "application/json" },
|
|
292
|
+
body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok)
|
|
295
|
+
throw new Error(`HTTP ${res.status}`);
|
|
151
296
|
const j = (await res.json());
|
|
152
|
-
|
|
153
|
-
if (rs.length)
|
|
154
|
-
return wrapUntrusted(fmt(rs), `web_search: ${q}`);
|
|
155
|
-
}
|
|
156
|
-
// Tavily failed → fall through to the keyless best-effort path.
|
|
157
|
-
}
|
|
158
|
-
// Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
|
|
159
|
-
const res = await fetch("https://html.duckduckgo.com/html/", {
|
|
160
|
-
method: "POST",
|
|
161
|
-
signal: ctrl.signal,
|
|
162
|
-
redirect: "follow",
|
|
163
|
-
headers: {
|
|
164
|
-
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
165
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
166
|
-
accept: "text/html",
|
|
297
|
+
return (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
|
|
167
298
|
},
|
|
168
|
-
body: `q=${encodeURIComponent(q)}`,
|
|
169
299
|
});
|
|
170
|
-
if (!res.ok)
|
|
171
|
-
return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
|
|
172
|
-
const results = parseSearchResults(await res.text(), limit);
|
|
173
|
-
if (!results.length)
|
|
174
|
-
return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
|
|
175
|
-
return wrapUntrusted(fmt(results), `web_search: ${q}`);
|
|
176
|
-
}
|
|
177
|
-
catch (e) {
|
|
178
|
-
return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
clearTimeout(timer);
|
|
182
300
|
}
|
|
301
|
+
primaryAttempts.push({
|
|
302
|
+
name: "Bing CN",
|
|
303
|
+
run: async () => {
|
|
304
|
+
const res = await searchFetch(`https://cn.bing.com/search?q=${encodeURIComponent(q)}&count=${limit}&setlang=zh-Hans`, {
|
|
305
|
+
method: "GET",
|
|
306
|
+
redirect: "follow",
|
|
307
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok)
|
|
310
|
+
throw new Error(`HTTP ${res.status}`);
|
|
311
|
+
return parseBingSearchResults(await res.text(), limit);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const primary = await firstSuccessfulSearch(primaryAttempts, failures);
|
|
315
|
+
if (primary)
|
|
316
|
+
return wrapUntrusted(fmt(primary), `web_search: ${q}`);
|
|
317
|
+
// Secondary sources run concurrently, keeping total failure latency bounded. Google is included as a
|
|
318
|
+
// user-friendly fallback where it is reachable, but is never the sole path (mainland often gets a shell).
|
|
319
|
+
const secondary = await firstSuccessfulSearch([
|
|
320
|
+
{
|
|
321
|
+
name: "Baidu",
|
|
322
|
+
run: async () => {
|
|
323
|
+
const res = await searchFetch(`https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=${limit}`, {
|
|
324
|
+
method: "GET",
|
|
325
|
+
redirect: "follow",
|
|
326
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok)
|
|
329
|
+
throw new Error(`HTTP ${res.status}`);
|
|
330
|
+
return parseBaiduSearchResults(await res.text(), limit);
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "Google",
|
|
335
|
+
run: async () => {
|
|
336
|
+
const res = await searchFetch(`https://www.google.com/search?q=${encodeURIComponent(q)}&num=${limit}&hl=zh-CN`, {
|
|
337
|
+
method: "GET",
|
|
338
|
+
redirect: "follow",
|
|
339
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
340
|
+
});
|
|
341
|
+
if (!res.ok)
|
|
342
|
+
throw new Error(`HTTP ${res.status}`);
|
|
343
|
+
return parseGoogleSearchResults(await res.text(), limit);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "DuckDuckGo",
|
|
348
|
+
run: async () => {
|
|
349
|
+
const res = await searchFetch("https://html.duckduckgo.com/html/", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
redirect: "follow",
|
|
352
|
+
headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
|
|
353
|
+
body: `q=${encodeURIComponent(q)}`,
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok)
|
|
356
|
+
throw new Error(`HTTP ${res.status}`);
|
|
357
|
+
return parseSearchResults(await res.text(), limit);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
], failures);
|
|
361
|
+
if (secondary)
|
|
362
|
+
return wrapUntrusted(fmt(secondary), `web_search: ${q}`);
|
|
363
|
+
return `Search failed across available providers (${failures.join("; ")}). Check connectivity/proxy, configure HARA_SEARCH_API_KEY, or web_fetch a known URL.`;
|
|
183
364
|
},
|
|
184
365
|
});
|
|
185
366
|
registerTool({
|
|
@@ -232,6 +413,11 @@ registerTool({
|
|
|
232
413
|
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
233
414
|
if (text.length > cap)
|
|
234
415
|
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
416
|
+
if (/html/i.test(ct) && looksLikeJsRenderedShell(raw, text)) {
|
|
417
|
+
const hint = "This page appears to be JavaScript-rendered; web_fetch received only the SPA shell and does not execute page scripts. " +
|
|
418
|
+
"Use an available browser/web skill for the rendered page, or the site's authenticated API/connector (for example the Feishu Docs API).";
|
|
419
|
+
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\n${hint}`, current.href)}`;
|
|
420
|
+
}
|
|
235
421
|
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
|
|
236
422
|
}
|
|
237
423
|
catch (e) {
|
package/dist/tui/run.js
CHANGED
|
@@ -4,36 +4,39 @@
|
|
|
4
4
|
import { render, Box, Text, useApp, useInput } from "ink";
|
|
5
5
|
import { createElement } from "react";
|
|
6
6
|
import { App } from "./App.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// resize events during a window drag collapses to one clear.
|
|
15
|
-
const out = process.stdout;
|
|
16
|
-
let pending = false;
|
|
7
|
+
/** Ink 6.8 clears before repainting when a terminal gets narrower, but not when it gets wider. Install a
|
|
8
|
+
* complementary WIDTH-only clear ahead of Ink's own resize listener so Ink's normal layout pass redraws
|
|
9
|
+
* the frame immediately afterwards. Never clear on a rows-only resize: `instance.clear()` erases the
|
|
10
|
+
* dynamic region without scheduling a React render, which made an idle input box disappear when a user
|
|
11
|
+
* dragged only the top/bottom edge of the terminal window. Exported for a small ordering regression test. */
|
|
12
|
+
export function installResizeRepaint(out, instance) {
|
|
13
|
+
let lastColumns = out.columns;
|
|
17
14
|
const onResize = () => {
|
|
18
|
-
|
|
15
|
+
const columns = out.columns;
|
|
16
|
+
if (!columns || columns === lastColumns)
|
|
19
17
|
return;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/* best-effort — never let a repaint fix crash the session */
|
|
28
|
-
}
|
|
29
|
-
});
|
|
18
|
+
lastColumns = columns;
|
|
19
|
+
try {
|
|
20
|
+
instance.clear();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* best-effort — never let a repaint fix crash the session */
|
|
24
|
+
}
|
|
30
25
|
};
|
|
31
|
-
|
|
26
|
+
// Ink registered its listener inside render() already. Prepending is essential: clearing AFTER Ink's
|
|
27
|
+
// repaint is precisely what leaves the prompt blank until some unrelated state update happens.
|
|
28
|
+
out.prependListener("resize", onResize);
|
|
29
|
+
return () => out.off("resize", onResize);
|
|
30
|
+
}
|
|
31
|
+
export async function runTui(props) {
|
|
32
|
+
const instance = render(createElement(App, props));
|
|
33
|
+
const out = process.stdout;
|
|
34
|
+
const removeResizeRepaint = installResizeRepaint(out, instance);
|
|
32
35
|
try {
|
|
33
36
|
await instance.waitUntilExit();
|
|
34
37
|
}
|
|
35
38
|
finally {
|
|
36
|
-
|
|
39
|
+
removeResizeRepaint();
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
// A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
|