@nanhara/hara 0.132.3 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
2
+ import { homedir, platform } from "node:os";
3
3
  import { resolve, isAbsolute } from "node:path";
4
4
  import { stdout as procOut } from "node:process";
5
5
  import { registerTool } from "./registry.js";
@@ -14,8 +14,16 @@ import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
14
14
  import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
15
15
  import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
16
16
  import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
17
+ import { loadConfig } from "../config.js";
18
+ import { commandHasPackageRegistry, normalizePackageRegistry, packageRegistryEnv } from "../package-registry.js";
17
19
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
18
20
  const MAX = 100_000;
21
+ const MAX_PYTHON_SOURCE_BYTES = 500_000;
22
+ /** Python source is delivered over stdin, so one-shot library operations never need a durable helper
23
+ * script. Keep the command itself fixed: model-authored source must not be interpolated into a shell. */
24
+ export function pythonStdinCommand(plat = platform()) {
25
+ return plat === "win32" ? "py -3 -" : "python3 -";
26
+ }
19
27
  /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
20
28
  export function isPackageInstallCommand(command) {
21
29
  return /(?:^|[;&|]\s*)(?:npm\s+(?:i|install|ci)\b|pnpm\s+(?:i|install|add)\b|yarn(?:\s+(?:install|add))?(?:\s|$)|bun\s+(?:i|install|add)\b)/i.test(command.trim());
@@ -149,7 +157,9 @@ registerTool({
149
157
  });
150
158
  registerTool({
151
159
  name: "write_file",
152
- description: "Create or overwrite a UTF-8 text file (creates parent directories).",
160
+ description: "Create or overwrite a UTF-8 text file (creates parent directories). This is text-only: do not use " +
161
+ "it for binary Office files or merely to save a one-shot Python helper before executing it; send " +
162
+ "one-shot Python source directly to the python tool instead.",
153
163
  input_schema: {
154
164
  type: "object",
155
165
  properties: {
@@ -200,6 +210,54 @@ registerTool({
200
210
  return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
201
211
  },
202
212
  });
213
+ registerTool({
214
+ name: "python",
215
+ description: "Execute Python 3 source directly from this tool input over stdin; no .py helper file is created. " +
216
+ "Prefer this for one-shot library APIs such as python-docx. When editing an existing user document, " +
217
+ "keep the original path as the canonical output unless the user explicitly requests a copy/version. " +
218
+ "Use a temporary output only for atomic replacement and remove it in finally/on failure.",
219
+ input_schema: {
220
+ type: "object",
221
+ properties: {
222
+ code: { type: "string", description: "Python 3 source executed from stdin, never written to a .py file" },
223
+ timeout_ms: { type: "number", description: "default 300000 (5 min), bounded to 1s..1h" },
224
+ },
225
+ required: ["code"],
226
+ },
227
+ kind: "exec",
228
+ requiresProjectWorkspace: true,
229
+ async run(input, ctx) {
230
+ if (typeof input.code !== "string" || !input.code.trim()) {
231
+ return "Error: python `code` must be a non-empty string. Nothing executed.";
232
+ }
233
+ if (Buffer.byteLength(input.code, "utf8") > MAX_PYTHON_SOURCE_BYTES) {
234
+ return `Error: python source exceeds ${MAX_PYTHON_SOURCE_BYTES} bytes. Split the operation into smaller direct calls; do not create a helper script.`;
235
+ }
236
+ const protectedReason = sensitiveShellCommandReason(input.code, ctx.cwd);
237
+ if (protectedReason) {
238
+ return (`Blocked: Python source crosses Hara's protected secret boundary (${protectedReason}). ` +
239
+ "This deny is not bypassed by direct stdin execution or full-auto.");
240
+ }
241
+ const command = pythonStdinCommand();
242
+ try {
243
+ const result = await runShell(command, ctx.cwd, ctx.sandbox ?? "off", {
244
+ timeout: shellTimeoutMs(command, input.timeout_ms),
245
+ maxBuffer: 1_000_000,
246
+ signal: ctx.signal,
247
+ input: input.code,
248
+ });
249
+ const rendered = redactToolSubprocessOutput(capHeadTail([result.stdout, result.stderr].filter(Boolean).join("\n"))).trim();
250
+ return rendered ? `Python completed without creating a helper script.\n${rendered}` : "Python completed without creating a helper script.";
251
+ }
252
+ catch (error) {
253
+ const rendered = redactToolSubprocessOutput(capHeadTail([error?.stdout, error?.stderr].filter(Boolean).join("\n"))).trim();
254
+ if (error?.code === 127) {
255
+ return "Error: Python 3 is not available on PATH. Install Python 3 and the required library, then retry; no helper script was created.";
256
+ }
257
+ return `Python failed: ${error?.message ?? String(error)}.${rendered ? `\n${rendered}` : ""}`;
258
+ }
259
+ },
260
+ });
203
261
  registerTool({
204
262
  name: "bash",
205
263
  description: "Run a shell command in the working directory; returns combined stdout/stderr.",
@@ -209,6 +267,7 @@ registerTool({
209
267
  command: { type: "string" },
210
268
  timeout_ms: { type: "number", description: "default 300000 (5 min), or 900000 (15 min) for package installs; bounded to 1s..1h" },
211
269
  background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); package installs stay foreground unless explicitly requested" },
270
+ registry: { type: "string", description: "for package installs only: npmjs, npmmirror, or an HTTP(S) registry URL; injected as environment, never shell text" },
212
271
  },
213
272
  required: ["command"],
214
273
  },
@@ -228,6 +287,25 @@ registerTool({
228
287
  if (input.background !== undefined && typeof input.background !== "boolean") {
229
288
  return "Error: `background` must be a boolean (true or false), not a string or another truthy value.";
230
289
  }
290
+ const packageInstall = isPackageInstallCommand(String(input.command ?? ""));
291
+ if (input.registry !== undefined && typeof input.registry !== "string") {
292
+ return "Error: `registry` must be a string such as npmmirror or https://registry.npmjs.org/.";
293
+ }
294
+ if (input.registry !== undefined && !packageInstall) {
295
+ return "Error: `registry` applies only to npm/pnpm/yarn/bun install commands.";
296
+ }
297
+ if (input.registry !== undefined && commandHasPackageRegistry(String(input.command ?? ""))) {
298
+ return "Error: choose one package-registry control: remove either bash.registry or the command's --registry argument.";
299
+ }
300
+ let registry;
301
+ if (packageInstall && !commandHasPackageRegistry(String(input.command ?? ""))) {
302
+ try {
303
+ registry = normalizePackageRegistry(input.registry ?? loadConfig({ cwd: ctx.cwd }).packageRegistry);
304
+ }
305
+ catch (error) {
306
+ return `Error: invalid package registry: ${error?.message ?? "unsupported value"}. Nothing executed.`;
307
+ }
308
+ }
231
309
  const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
232
310
  if (protectedReason) {
233
311
  return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
@@ -238,9 +316,11 @@ registerTool({
238
316
  "Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
239
317
  }
240
318
  if (input.background) {
241
- const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
319
+ const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off", registry ? packageRegistryEnv(registry) : {});
242
320
  const safeCommand = redactToolSubprocessOutput(String(input.command));
243
- return `Started background job ${id}: \`${safeCommand}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
321
+ return `Started background job ${id}: \`${safeCommand}\`.` +
322
+ (registry ? " Explicit package registry override applied." : "") +
323
+ ` Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
244
324
  }
245
325
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
246
326
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
@@ -278,10 +358,14 @@ registerTool({
278
358
  maxBuffer: 10 * 1024 * 1024,
279
359
  onData: live,
280
360
  signal: ctx.signal,
361
+ ...(registry ? { env: packageRegistryEnv(registry) } : {}),
281
362
  });
282
363
  flushLive();
283
364
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
284
- return capHeadTail(redactToolSubprocessOutput(combined.trim() || "(no output)"));
365
+ // The endpoint can be a private hostname/path. Confirmation is useful, but echoing it back into the
366
+ // model transcript is unnecessary and may expose organization-specific routing metadata.
367
+ const notice = registry ? "hara: explicit package registry override applied\n" : "";
368
+ return capHeadTail(redactToolSubprocessOutput(notice + (combined.trim() || "(no output)")));
285
369
  }
286
370
  catch (e) {
287
371
  flushLive();
@@ -293,6 +377,11 @@ registerTool({
293
377
  `\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
294
378
  `a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
295
379
  `a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
380
+ if (packageInstall && !registry && !commandHasPackageRegistry(String(input.command ?? ""))) {
381
+ base +=
382
+ " For public npm dependencies, an explicit retry may set bash.registry=\"npmmirror\" (or launch with --registry npmmirror). " +
383
+ "Hara does not silently redirect installs because private scopes and registry trust must be preserved.";
384
+ }
296
385
  }
297
386
  // Network fault tolerance — if this was a genuine host-unreachability (connect timeout / DNS, NOT
298
387
  // auth / 404 / connection-refused), remember the host so we fast-fail future ops to it this session.
@@ -0,0 +1,417 @@
1
+ // Optional isolated headless rendering for web_fetch. The browser uses a fresh temporary profile and is
2
+ // forced through a loopback validating proxy: every top-level/subresource destination is resolved once,
3
+ // rejected if any answer is private/internal, and connected by the approved IP. This preserves web_fetch's
4
+ // SSRF boundary across JavaScript redirects instead of handing Chrome an unchecked public URL.
5
+ import { spawn } from "node:child_process";
6
+ import { once } from "node:events";
7
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
8
+ import { createServer, request as httpRequest, } from "node:http";
9
+ import { request as httpsRequest } from "node:https";
10
+ import { createConnection } from "node:net";
11
+ import { tmpdir, platform } from "node:os";
12
+ import { delimiter, isAbsolute, join } from "node:path";
13
+ import { connect as tlsConnect } from "node:tls";
14
+ import { terminateSubprocessTree, toolSubprocessEnv } from "../security/subprocess-env.js";
15
+ const MAX_BROWSER_REQUESTS = 192;
16
+ const MAX_BROWSER_BYTES = 64 * 1024 * 1024;
17
+ const MAX_BROWSER_HTML_BYTES = 4 * 1024 * 1024;
18
+ const BROWSER_TIMEOUT_MS = 25_000;
19
+ const BROWSER_SOCKET_TIMEOUT_MS = 12_000;
20
+ function executableCandidates(env, plat) {
21
+ const explicit = String(env.HARA_BROWSER_PATH ?? "").trim();
22
+ const fixed = plat === "darwin"
23
+ ? [
24
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
27
+ ]
28
+ : plat === "win32"
29
+ ? [
30
+ env.ProgramFiles && join(env.ProgramFiles, "Google/Chrome/Application/chrome.exe"),
31
+ env["ProgramFiles(x86)"] && join(env["ProgramFiles(x86)"], "Google/Chrome/Application/chrome.exe"),
32
+ env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Google/Chrome/Application/chrome.exe"),
33
+ env.ProgramFiles && join(env.ProgramFiles, "Microsoft/Edge/Application/msedge.exe"),
34
+ ]
35
+ : [
36
+ "/usr/bin/google-chrome",
37
+ "/usr/bin/google-chrome-stable",
38
+ "/usr/bin/chromium",
39
+ "/usr/bin/chromium-browser",
40
+ "/usr/bin/microsoft-edge",
41
+ ];
42
+ const names = plat === "win32"
43
+ ? ["chrome.exe", "msedge.exe", "chromium.exe"]
44
+ : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
45
+ const onPath = String(env.PATH ?? "")
46
+ .split(delimiter)
47
+ .filter(Boolean)
48
+ .flatMap((dir) => names.map((name) => join(dir, name)));
49
+ return [explicit && isAbsolute(explicit) ? explicit : "", ...fixed, ...onPath]
50
+ .filter((value) => !!value);
51
+ }
52
+ /** Find an already-installed Chromium-family browser. Hara never downloads or mutates a browser install. */
53
+ export function findHeadlessBrowser(env = process.env, plat = platform()) {
54
+ return executableCandidates(env, plat).find((path) => {
55
+ try {
56
+ return existsSync(path);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ }
63
+ function safeHeaders(headers) {
64
+ const result = {};
65
+ const dropped = new Set([
66
+ "connection", "proxy-authorization", "proxy-authenticate", "proxy-connection", "keep-alive",
67
+ "te", "trailer", "transfer-encoding", "upgrade",
68
+ ]);
69
+ for (const [name, value] of Object.entries(headers)) {
70
+ if (value === undefined || dropped.has(name.toLowerCase()))
71
+ continue;
72
+ result[name] = value;
73
+ }
74
+ return result;
75
+ }
76
+ function proxyAuth(proxy) {
77
+ if (!proxy.username && !proxy.password)
78
+ return undefined;
79
+ return `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`;
80
+ }
81
+ function pinnedAuthority(route, port) {
82
+ return `${route.family === 6 ? `[${route.address}]` : route.address}:${port}`;
83
+ }
84
+ function parseConnectTarget(authority) {
85
+ if (!authority || /[\s/@?#]/u.test(authority))
86
+ throw new Error("invalid CONNECT target");
87
+ const target = new URL(`https://${authority}`);
88
+ if (!target.hostname || target.username || target.password || target.pathname !== "/")
89
+ throw new Error("invalid CONNECT target");
90
+ return target;
91
+ }
92
+ function requestThroughUpstream(proxyUri, target, route, req, res, countBytes) {
93
+ const proxy = new URL(proxyUri);
94
+ if (proxy.protocol !== "http:" && proxy.protocol !== "https:")
95
+ throw new Error("unsupported upstream proxy");
96
+ const pinned = new URL(target.href);
97
+ pinned.hostname = route.family === 6 ? `[${route.address}]` : route.address;
98
+ pinned.username = "";
99
+ pinned.password = "";
100
+ const headers = { ...safeHeaders(req.headers), host: target.host };
101
+ const auth = proxyAuth(proxy);
102
+ if (auth)
103
+ headers["proxy-authorization"] = auth;
104
+ const options = {
105
+ protocol: proxy.protocol,
106
+ hostname: proxy.hostname,
107
+ port: proxy.port || undefined,
108
+ method: req.method,
109
+ path: pinned.href,
110
+ headers,
111
+ ...(proxy.protocol === "https:" ? { servername: proxy.hostname } : {}),
112
+ };
113
+ const upstream = (proxy.protocol === "https:" ? httpsRequest : httpRequest)(options, (remote) => {
114
+ res.writeHead(remote.statusCode ?? 502, remote.headers);
115
+ remote.once("error", () => { if (!res.destroyed)
116
+ res.end(); });
117
+ remote.on("data", (chunk) => {
118
+ if (!countBytes(chunk.length))
119
+ remote.destroy();
120
+ });
121
+ remote.pipe(res);
122
+ });
123
+ upstream.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => upstream.destroy(new Error("render proxy timeout")));
124
+ res.once("close", () => upstream.destroy());
125
+ upstream.once("error", () => {
126
+ if (!res.headersSent)
127
+ res.writeHead(502);
128
+ res.end("render proxy request failed");
129
+ });
130
+ req.pipe(upstream);
131
+ }
132
+ function requestDirect(target, route, req, res, countBytes) {
133
+ const upstream = httpRequest({
134
+ protocol: "http:",
135
+ hostname: route.address,
136
+ family: route.family,
137
+ port: target.port || 80,
138
+ method: req.method,
139
+ path: `${target.pathname}${target.search}`,
140
+ headers: { ...safeHeaders(req.headers), host: target.host },
141
+ }, (remote) => {
142
+ res.writeHead(remote.statusCode ?? 502, remote.headers);
143
+ remote.once("error", () => { if (!res.destroyed)
144
+ res.end(); });
145
+ remote.on("data", (chunk) => {
146
+ if (!countBytes(chunk.length))
147
+ remote.destroy();
148
+ });
149
+ remote.pipe(res);
150
+ });
151
+ upstream.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => upstream.destroy(new Error("render destination timeout")));
152
+ res.once("close", () => upstream.destroy());
153
+ upstream.once("error", () => {
154
+ if (!res.headersSent)
155
+ res.writeHead(502);
156
+ res.end("render proxy request failed");
157
+ });
158
+ req.pipe(upstream);
159
+ }
160
+ async function upstreamTunnel(proxyUri, authority, onSocket) {
161
+ const proxy = new URL(proxyUri);
162
+ if (proxy.protocol !== "http:" && proxy.protocol !== "https:")
163
+ throw new Error("unsupported upstream proxy");
164
+ const port = Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80));
165
+ const socket = proxy.protocol === "https:"
166
+ ? tlsConnect({ host: proxy.hostname, port, servername: proxy.hostname })
167
+ : createConnection({ host: proxy.hostname, port });
168
+ onSocket(socket);
169
+ socket.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => socket.destroy(new Error("upstream proxy timeout")));
170
+ await once(socket, proxy.protocol === "https:" ? "secureConnect" : "connect");
171
+ const auth = proxyAuth(proxy);
172
+ socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n` +
173
+ (auth ? `Proxy-Authorization: ${auth}\r\n` : "") +
174
+ "Connection: keep-alive\r\n\r\n");
175
+ return await new Promise((resolve, reject) => {
176
+ let buffered = Buffer.alloc(0);
177
+ const fail = () => reject(new Error("upstream proxy tunnel failed"));
178
+ const onData = (chunk) => {
179
+ buffered = Buffer.concat([buffered, chunk]);
180
+ if (buffered.length > 16 * 1024)
181
+ return fail();
182
+ const boundary = buffered.indexOf("\r\n\r\n");
183
+ if (boundary < 0)
184
+ return;
185
+ socket.off("data", onData);
186
+ socket.off("error", fail);
187
+ const status = Number(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/iu.exec(buffered.subarray(0, boundary).toString("latin1"))?.[1] ?? 0);
188
+ if (status < 200 || status >= 300)
189
+ return fail();
190
+ socket.setTimeout(BROWSER_TIMEOUT_MS, () => socket.destroy());
191
+ resolve({ socket, leftover: buffered.subarray(boundary + 4) });
192
+ };
193
+ socket.on("data", onData);
194
+ socket.once("error", fail);
195
+ }).catch((error) => {
196
+ socket.destroy();
197
+ throw error;
198
+ });
199
+ }
200
+ async function startValidatingProxy(resolveRoute) {
201
+ const sockets = new Set();
202
+ let requestCount = 0;
203
+ let byteCount = 0;
204
+ let closed = false;
205
+ const countRequest = () => !closed && ++requestCount <= MAX_BROWSER_REQUESTS;
206
+ const countBytes = (amount) => !closed && (byteCount += amount) <= MAX_BROWSER_BYTES;
207
+ const server = createServer(async (req, res) => {
208
+ if (!countRequest()) {
209
+ res.writeHead(429);
210
+ return void res.end("render request budget exceeded");
211
+ }
212
+ try {
213
+ const target = new URL(String(req.url));
214
+ if (target.protocol !== "http:" || target.username || target.password)
215
+ throw new Error("invalid proxied URL");
216
+ const route = await resolveRoute(target);
217
+ if (route.proxyUri)
218
+ requestThroughUpstream(route.proxyUri, target, route, req, res, countBytes);
219
+ else
220
+ requestDirect(target, route, req, res, countBytes);
221
+ }
222
+ catch {
223
+ res.writeHead(502);
224
+ res.end("render destination blocked");
225
+ }
226
+ });
227
+ server.on("connection", (socket) => {
228
+ sockets.add(socket);
229
+ socket.once("close", () => sockets.delete(socket));
230
+ });
231
+ server.on("clientError", (_error, socket) => socket.destroy());
232
+ server.on("connect", async (req, client, head) => {
233
+ client.on("error", () => { });
234
+ if (!countRequest()) {
235
+ client.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
236
+ return;
237
+ }
238
+ try {
239
+ const target = parseConnectTarget(String(req.url));
240
+ const port = Number(target.port || 443);
241
+ const route = await resolveRoute(target);
242
+ const authority = pinnedAuthority(route, port);
243
+ let tunnel;
244
+ if (route.proxyUri) {
245
+ tunnel = await upstreamTunnel(route.proxyUri, authority, (socket) => {
246
+ sockets.add(socket);
247
+ socket.once("close", () => sockets.delete(socket));
248
+ });
249
+ }
250
+ else {
251
+ const socket = createConnection({ host: route.address, port, family: route.family });
252
+ sockets.add(socket);
253
+ socket.once("close", () => sockets.delete(socket));
254
+ socket.setTimeout(BROWSER_SOCKET_TIMEOUT_MS, () => socket.destroy(new Error("render destination timeout")));
255
+ await once(socket, "connect");
256
+ socket.setTimeout(BROWSER_TIMEOUT_MS, () => socket.destroy());
257
+ tunnel = { socket, leftover: Buffer.alloc(0) };
258
+ }
259
+ const remote = tunnel.socket;
260
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
261
+ if (tunnel.leftover.length)
262
+ client.write(tunnel.leftover);
263
+ if (head.length)
264
+ remote.write(head);
265
+ // Browsers routinely cancel speculative/subresource tunnels. A pipe destination can then raise EPIPE;
266
+ // pair the two endpoints explicitly so cancellation is ordinary cleanup, never a Hara process crash.
267
+ client.on("error", () => remote.destroy());
268
+ remote.on("error", () => client.destroy());
269
+ client.once("close", () => remote.destroy());
270
+ remote.once("close", () => client.destroy());
271
+ remote.on("data", (chunk) => {
272
+ if (!countBytes(chunk.length)) {
273
+ remote.destroy();
274
+ client.destroy();
275
+ }
276
+ });
277
+ client.on("data", (chunk) => {
278
+ if (!countBytes(chunk.length)) {
279
+ remote.destroy();
280
+ client.destroy();
281
+ }
282
+ });
283
+ client.pipe(remote);
284
+ remote.pipe(client);
285
+ }
286
+ catch {
287
+ client.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
288
+ }
289
+ });
290
+ server.listen(0, "127.0.0.1");
291
+ await once(server, "listening");
292
+ const address = server.address();
293
+ if (!address || typeof address === "string")
294
+ throw new Error("could not bind render proxy");
295
+ return {
296
+ url: `http://127.0.0.1:${address.port}`,
297
+ async close() {
298
+ if (closed)
299
+ return;
300
+ closed = true;
301
+ for (const socket of sockets)
302
+ socket.destroy();
303
+ await new Promise((resolve) => server.close(() => resolve()));
304
+ },
305
+ };
306
+ }
307
+ /** Render one page in an isolated profile. This is deliberately optional: callers classify it as computer
308
+ * use so ordinary read-only web_fetch never starts a JS engine without a user-approved render:true call. */
309
+ export async function renderHeadlessHtml(url, resolveRoute, parentSignal, env = process.env) {
310
+ const browser = findHeadlessBrowser(env);
311
+ if (!browser)
312
+ return { error: "browser-unavailable" };
313
+ if (parentSignal?.aborted)
314
+ return { error: "failed" };
315
+ const profile = mkdtempSync(join(tmpdir(), "hara-headless-web-"));
316
+ let proxy;
317
+ let child;
318
+ let timer;
319
+ let cancelTree;
320
+ try {
321
+ // Keep proxy startup inside the cleanup boundary: a bind/listen failure must not strand the fresh
322
+ // browser profile created above.
323
+ proxy = await startValidatingProxy(resolveRoute);
324
+ const args = [
325
+ "--headless=new",
326
+ "--disable-background-networking",
327
+ "--disable-component-update",
328
+ "--disable-default-apps",
329
+ "--disable-extensions",
330
+ "--disable-sync",
331
+ "--disable-translate",
332
+ "--disable-quic",
333
+ "--disable-dev-shm-usage",
334
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
335
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE ::1",
336
+ "--metrics-recording-only",
337
+ "--mute-audio",
338
+ "--no-default-browser-check",
339
+ "--no-first-run",
340
+ "--password-store=basic",
341
+ "--use-mock-keychain",
342
+ `--user-data-dir=${profile}`,
343
+ `--disk-cache-dir=${join(profile, "cache")}`,
344
+ `--proxy-server=${proxy.url}`,
345
+ "--proxy-bypass-list=<-loopback>",
346
+ "--virtual-time-budget=8000",
347
+ "--dump-dom",
348
+ url.href,
349
+ ];
350
+ const processGroup = platform() !== "win32";
351
+ child = spawn(browser, args, {
352
+ stdio: ["ignore", "pipe", "pipe"],
353
+ detached: processGroup,
354
+ env: toolSubprocessEnv(env, {
355
+ HARA_BROWSER_PATH: undefined,
356
+ HARA_WEB_PROXY: undefined,
357
+ HTTPS_PROXY: undefined,
358
+ HTTP_PROXY: undefined,
359
+ https_proxy: undefined,
360
+ http_proxy: undefined,
361
+ }),
362
+ });
363
+ const result = await new Promise((resolve) => {
364
+ const chunks = [];
365
+ let size = 0;
366
+ let settled = false;
367
+ const finish = (value) => {
368
+ if (settled)
369
+ return;
370
+ settled = true;
371
+ if (timer)
372
+ clearTimeout(timer);
373
+ parentSignal?.removeEventListener("abort", abort);
374
+ resolve(value);
375
+ };
376
+ const stop = (value) => {
377
+ if (child && !cancelTree)
378
+ cancelTree = terminateSubprocessTree(child, { processGroup, force: true });
379
+ finish(value);
380
+ };
381
+ const abort = () => stop({ error: "failed" });
382
+ parentSignal?.addEventListener("abort", abort, { once: true });
383
+ timer = setTimeout(() => stop({ error: "timed-out" }), BROWSER_TIMEOUT_MS);
384
+ child.stdout.on("data", (chunk) => {
385
+ size += chunk.length;
386
+ if (size > MAX_BROWSER_HTML_BYTES)
387
+ return stop({ error: "output-too-large" });
388
+ chunks.push(chunk);
389
+ // Chrome can keep utility processes/pipes alive briefly after --dump-dom has emitted the complete
390
+ // document (especially with a validating proxy). The serialized closing tag is the result boundary;
391
+ // return it immediately and terminate the isolated process tree instead of false-timing-out.
392
+ const tail = Buffer.concat(chunks.slice(-3)).toString("utf8").slice(-256);
393
+ if (/<\/html>\s*$/iu.test(tail)) {
394
+ if (child && !cancelTree)
395
+ cancelTree = terminateSubprocessTree(child, { processGroup, force: true });
396
+ finish({ html: Buffer.concat(chunks).toString("utf8") });
397
+ }
398
+ });
399
+ // Browser diagnostics can contain machine-specific paths. Drain but never return them to the model.
400
+ child.stderr.resume();
401
+ child.once("error", () => finish({ error: "failed" }));
402
+ child.once("close", (code) => {
403
+ if (code !== 0 || !chunks.length)
404
+ return finish({ error: "failed" });
405
+ finish({ html: Buffer.concat(chunks).toString("utf8") });
406
+ });
407
+ });
408
+ return result;
409
+ }
410
+ finally {
411
+ if (timer)
412
+ clearTimeout(timer);
413
+ cancelTree?.();
414
+ await proxy?.close().catch(() => { });
415
+ rmSync(profile, { recursive: true, force: true });
416
+ }
417
+ }
@@ -12,6 +12,18 @@ const MAX_AUTO_PROJECT_CANDIDATES = 80;
12
12
  const MAX_AUTO_PROJECT_CHARS = 8_000_000;
13
13
  const MAX_MESSAGE_SCAN_CHARS = 120_000;
14
14
  const MAX_EXCERPT_CHARS = 700;
15
+ const HISTORICAL_REFERENCE = [
16
+ /(?:之前|以前|上次|前一(?:次|个)|此前|我们前面)(?:的|聊过|讨论过|说过|做过|处理过|提过|那个|那次|任务|问题|方案|对话|会话|项目|内容)?/u,
17
+ /(?:继续|接着)(?:之前|以前|上次|前一(?:次|个)|此前)(?:的|那个|那次)?/u,
18
+ /(?:还记得|记不记得|你记得)(?:之前|以前|上次|我们)?/u,
19
+ /\b(?:previous|prior|earlier|last)\s+(?:chat|session|conversation|time|task|issue|discussion|project)\b/iu,
20
+ /\b(?:continue|pick up)\s+(?:from|where)\b/iu,
21
+ /\b(?:do you remember|we (?:discussed|talked about|worked on)|you said before)\b/iu,
22
+ ];
23
+ const HISTORICAL_REFERENCE_NEGATION = [
24
+ /(?:不要|不用|无需|别)(?:查|找|看|搜索|读取|回忆).{0,8}(?:之前|以前|上次|历史|旧会话)/u,
25
+ /\b(?:do not|don't|dont|no need to)\s+(?:search|read|look at|recall).{0,32}(?:previous|prior|earlier|history|old (?:chat|session))/iu,
26
+ ];
15
27
  function normalized(value) {
16
28
  return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
17
29
  }
@@ -23,6 +35,28 @@ export function sessionSearchTerms(query) {
23
35
  const terms = lexicalSearchTerms(query);
24
36
  return terms.length <= 32 ? terms : [...terms.slice(0, 16), ...terms.slice(-16)];
25
37
  }
38
+ /** Only search raw transcripts when the user explicitly points at older work. This makes ordinary turns
39
+ * deterministic and cheap while fixing the common "continue the task from our previous chat" case without
40
+ * relying on the model to notice and call session_search itself. Explicit opt-outs always win. */
41
+ export function sessionRecallQuery(messageValue) {
42
+ const message = String(messageValue ?? "").replace(/\s+/g, " ").trim();
43
+ if (!message || HISTORICAL_REFERENCE_NEGATION.some((pattern) => pattern.test(message)))
44
+ return null;
45
+ if (!HISTORICAL_REFERENCE.some((pattern) => pattern.test(message)))
46
+ return null;
47
+ return message.slice(0, 512);
48
+ }
49
+ /** Return an injectable, clearly untrusted recall block, or an empty string when no explicit cue/match
50
+ * exists. Audience/project enforcement remains centralized in searchSessionHistory. */
51
+ export async function automaticSessionRecall(message, ctx) {
52
+ const query = sessionRecallQuery(message);
53
+ if (!query)
54
+ return "";
55
+ const result = await searchSessionHistory(query, "auto", 3, ctx);
56
+ if (result === "(no session matches)" || result.startsWith("Error:") || result.startsWith("Blocked:"))
57
+ return "";
58
+ return `Automatic prior-session recall (triggered by the user's explicit historical reference):\n${result}`;
59
+ }
26
60
  function visibleMessages(history) {
27
61
  const out = [];
28
62
  for (const message of history) {