@nanhara/hara 0.131.1 → 0.132.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.
@@ -787,7 +787,9 @@ export async function startServe(opts, deps) {
787
787
  "session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
788
788
  "session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
789
789
  "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
790
- "settings.providers.list", "settings.providers.test", "settings.providers.save",
790
+ "settings.providers.list", "settings.providers.test", "settings.providers.save", "settings.gateways.list",
791
+ "settings.organizations.list", "settings.organizations.enroll", "settings.organizations.use",
792
+ "settings.organizations.remove", "settings.organizations.check",
791
793
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
792
794
  "artifact.import", "artifact.commit", "artifact.revert",
793
795
  "artifact.list", "artifact.get", "artifact.revisions",
@@ -1031,6 +1033,59 @@ export async function startServe(opts, deps) {
1031
1033
  const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
1032
1034
  return reply(rpcResult(id, redactSensitiveValue(deps.providerSettings(targetCwd)).value));
1033
1035
  }
1036
+ case "settings.gateways.list": {
1037
+ if (!deps.gatewayStatuses)
1038
+ return reply(rpcError(id, ERR.METHOD, "gateway status not supported by this server"));
1039
+ const gateways = await deps.gatewayStatuses();
1040
+ return reply(rpcResult(id, { gateways: redactSensitiveValue(gateways).value }));
1041
+ }
1042
+ case "settings.organizations.list": {
1043
+ if (!deps.organizationConnections)
1044
+ return reply(rpcError(id, ERR.METHOD, "organization settings not supported by this server"));
1045
+ const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
1046
+ return reply(rpcResult(id, redactSensitiveValue(deps.organizationConnections(targetCwd)).value));
1047
+ }
1048
+ case "settings.organizations.enroll": {
1049
+ if (!deps.enrollOrganizationConnection)
1050
+ return reply(rpcError(id, ERR.METHOD, "organization enrollment not supported by this server"));
1051
+ if (typeof p.id !== "string" ||
1052
+ typeof p.gatewayUrl !== "string" ||
1053
+ typeof p.code !== "string" ||
1054
+ (p.label !== undefined && typeof p.label !== "string") ||
1055
+ (p.activate !== undefined && typeof p.activate !== "boolean")) {
1056
+ return reply(rpcError(id, ERR.PARAMS, "id + gatewayUrl + code required; optional label/activate have invalid types"));
1057
+ }
1058
+ const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
1059
+ const input = {
1060
+ id: p.id,
1061
+ gatewayUrl: p.gatewayUrl,
1062
+ code: p.code,
1063
+ ...(p.label !== undefined ? { label: p.label } : {}),
1064
+ ...(p.activate !== undefined ? { activate: p.activate } : {}),
1065
+ };
1066
+ const result = await deps.enrollOrganizationConnection(input, targetCwd);
1067
+ return reply(rpcResult(id, redactSensitiveValue(result, [p.code]).value));
1068
+ }
1069
+ case "settings.organizations.use":
1070
+ case "settings.organizations.remove":
1071
+ case "settings.organizations.check": {
1072
+ if (typeof p.id !== "string")
1073
+ return reply(rpcError(id, ERR.PARAMS, "organization connection id required"));
1074
+ const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
1075
+ if (req.method === "settings.organizations.use") {
1076
+ if (!deps.useOrganizationConnection)
1077
+ return reply(rpcError(id, ERR.METHOD, "organization switching not supported by this server"));
1078
+ return reply(rpcResult(id, redactSensitiveValue(deps.useOrganizationConnection(p.id, targetCwd)).value));
1079
+ }
1080
+ if (req.method === "settings.organizations.remove") {
1081
+ if (!deps.removeOrganizationConnection)
1082
+ return reply(rpcError(id, ERR.METHOD, "organization removal not supported by this server"));
1083
+ return reply(rpcResult(id, redactSensitiveValue(deps.removeOrganizationConnection(p.id, targetCwd)).value));
1084
+ }
1085
+ if (!deps.checkOrganizationConnection)
1086
+ return reply(rpcError(id, ERR.METHOD, "organization connection check not supported by this server"));
1087
+ return reply(rpcResult(id, redactSensitiveValue(await deps.checkOrganizationConnection(p.id, targetCwd)).value));
1088
+ }
1034
1089
  case "settings.providers.test":
1035
1090
  case "settings.providers.save": {
1036
1091
  const callback = req.method === "settings.providers.test" ? deps.testProviderSettings : deps.saveProviderSettings;
package/dist/tools/web.js CHANGED
@@ -7,7 +7,9 @@ import { lookup } from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
8
  import { request as httpRequest } from "node:http";
9
9
  import { request as httpsRequest } from "node:https";
10
+ import { ProxyAgent, fetch as undiciFetch, request as undiciRequest } from "undici";
10
11
  import { wrapUntrusted } from "../security/external-content.js";
12
+ import { loadConfig } from "../config.js";
11
13
  const MAX = 60_000;
12
14
  const SEARCH_ATTEMPT_MS = 8_000;
13
15
  const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
@@ -116,9 +118,179 @@ export async function resolvePublicHost(hostname, resolver = lookup) {
116
118
  const chosen = addrs[0];
117
119
  return { address: chosen.address, family: chosen.family };
118
120
  }
121
+ function nonBlank(value) {
122
+ return value?.trim() || undefined;
123
+ }
124
+ function defaultPort(url) {
125
+ return url.port || (url.protocol === "https:" ? "443" : "80");
126
+ }
127
+ /** Common NO_PROXY matching: exact host, domain suffix (`.example.com` / `*.example.com`), optional port,
128
+ * and `*`. This decision always uses the original public hostname, never the later pinned IP. */
129
+ export function bypassesWebProxy(url, noProxyValue) {
130
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
131
+ const port = defaultPort(url);
132
+ for (const raw of noProxyValue?.split(/[\s,]+/u) ?? []) {
133
+ let rule = raw.trim().toLowerCase();
134
+ if (!rule)
135
+ continue;
136
+ if (rule === "*")
137
+ return true;
138
+ try {
139
+ if (/^https?:\/\//u.test(rule)) {
140
+ const parsed = new URL(rule);
141
+ if (parsed.port && parsed.port !== port)
142
+ continue;
143
+ rule = parsed.hostname.toLowerCase();
144
+ }
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ let rulePort;
150
+ if (rule.startsWith("[")) {
151
+ const match = /^\[([^\]]+)\](?::(\d+))?$/u.exec(rule);
152
+ if (!match)
153
+ continue;
154
+ rule = match[1];
155
+ rulePort = match[2];
156
+ }
157
+ else {
158
+ const match = /^([^:]+):(\d+)$/u.exec(rule);
159
+ if (match) {
160
+ rule = match[1];
161
+ rulePort = match[2];
162
+ }
163
+ }
164
+ if (rulePort && rulePort !== port)
165
+ continue;
166
+ rule = rule.replace(/\.$/u, "");
167
+ const suffix = rule.startsWith("*.") ? rule.slice(2) : rule.startsWith(".") ? rule.slice(1) : "";
168
+ if (suffix ? hostname === suffix || hostname.endsWith(`.${suffix}`) : hostname === rule)
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ function normalizedProxyUri(value) {
174
+ try {
175
+ const parsed = new URL(value);
176
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:")
177
+ || !parsed.hostname
178
+ || parsed.pathname !== "/"
179
+ || parsed.search
180
+ || parsed.hash) {
181
+ throw new Error("unsupported proxy URL");
182
+ }
183
+ return parsed.href;
184
+ }
185
+ catch {
186
+ throw new Error("web proxy configuration is invalid; use an HTTP(S) proxy URL without a path, query, or fragment");
187
+ }
188
+ }
189
+ /** Resolve one proxy without mutating Undici's global dispatcher. Standard lowercase variables take
190
+ * precedence over uppercase, and HTTPS falls back to HTTP_PROXY as documented by EnvHttpProxyAgent. */
191
+ export function selectWebProxy(url, configuredProxy, env = process.env) {
192
+ const noProxy = nonBlank(env.no_proxy) ?? nonBlank(env.NO_PROXY);
193
+ if (bypassesWebProxy(url, noProxy))
194
+ return undefined;
195
+ const haraProxy = nonBlank(env.HARA_WEB_PROXY);
196
+ if (haraProxy)
197
+ return { uri: normalizedProxyUri(haraProxy), source: "hara-env" };
198
+ const protocolProxy = url.protocol === "https:"
199
+ ? nonBlank(env.https_proxy) ?? nonBlank(env.HTTPS_PROXY) ?? nonBlank(env.http_proxy) ?? nonBlank(env.HTTP_PROXY)
200
+ : nonBlank(env.http_proxy) ?? nonBlank(env.HTTP_PROXY);
201
+ if (protocolProxy)
202
+ return { uri: normalizedProxyUri(protocolProxy), source: "environment" };
203
+ return configuredProxy ? { uri: normalizedProxyUri(configuredProxy), source: "config" } : undefined;
204
+ }
205
+ function webProxy(url, cwd) {
206
+ return selectWebProxy(url, loadConfig({ cwd }).proxy);
207
+ }
208
+ function proxySafeError(error) {
209
+ const candidate = error;
210
+ const code = typeof candidate?.code === "string"
211
+ ? candidate.code
212
+ : typeof candidate?.cause?.code === "string"
213
+ ? candidate.cause.code
214
+ : undefined;
215
+ const suffix = code && /^[A-Z][A-Z0-9_]{1,40}$/u.test(code) ? ` (${code})` : "";
216
+ const result = new Error(`proxy request failed${suffix}`);
217
+ result.name = typeof candidate?.name === "string" && /^(?:Abort|Timeout)Error$/u.test(candidate.name)
218
+ ? candidate.name
219
+ : "Error";
220
+ return result;
221
+ }
222
+ function safeNetworkError(error) {
223
+ const candidate = error;
224
+ if (candidate?.name === "AbortError" || candidate?.name === "TimeoutError")
225
+ return "timed out (30s)";
226
+ const rawCode = typeof candidate?.code === "string" ? candidate.code : candidate?.cause?.code;
227
+ const code = typeof rawCode === "string" && /^[A-Z][A-Z0-9_]{1,40}$/u.test(rawCode) ? ` [${rawCode}]` : "";
228
+ const message = String(candidate?.message ?? "network request failed")
229
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, "$1[credentials]@")
230
+ .replace(/[\u0000-\u001f\u007f]/gu, " ")
231
+ .replace(/\s+/gu, " ")
232
+ .slice(0, 240);
233
+ return `${message}${code}`;
234
+ }
235
+ function pinnedUrl(url, pinned) {
236
+ const result = new URL(url.href);
237
+ result.hostname = pinned.family === 6 ? `[${pinned.address}]` : pinned.address;
238
+ result.username = "";
239
+ result.password = "";
240
+ return result;
241
+ }
242
+ function responseHeaders(values) {
243
+ const headers = new Headers();
244
+ for (const [name, value] of Object.entries(values)) {
245
+ if (Array.isArray(value))
246
+ for (const item of value)
247
+ headers.append(name, item);
248
+ else if (value !== undefined)
249
+ headers.append(name, String(value));
250
+ }
251
+ return headers;
252
+ }
119
253
  /** One HTTP hop whose TCP socket is pinned to the address approved above. `Host` and TLS SNI retain the
120
254
  * original hostname, so virtual hosting/certificate checks work without a second DNS lookup. */
121
- async function requestPinned(url, pinned, signal) {
255
+ export async function requestPinned(url, pinned, signal, proxy) {
256
+ if (proxy) {
257
+ const servername = url.hostname.replace(/^\[|\]$/g, "");
258
+ const agent = new ProxyAgent({
259
+ uri: proxy.uri,
260
+ proxyTunnel: true,
261
+ ...(url.protocol === "https:" && !isIP(servername) ? { requestTls: { servername } } : {}),
262
+ });
263
+ try {
264
+ const response = await undiciRequest(pinnedUrl(url, pinned), {
265
+ dispatcher: agent,
266
+ method: "GET",
267
+ signal,
268
+ headers: {
269
+ host: url.host,
270
+ "user-agent": "hara-cli",
271
+ accept: "text/html,text/plain,application/json,*/*",
272
+ },
273
+ });
274
+ let released = false;
275
+ return {
276
+ status: response.statusCode,
277
+ headers: responseHeaders(response.headers),
278
+ body: response.body,
279
+ async release(discard = false) {
280
+ if (released)
281
+ return;
282
+ released = true;
283
+ if (discard)
284
+ response.body.destroy();
285
+ await agent.close();
286
+ },
287
+ };
288
+ }
289
+ catch (error) {
290
+ await agent.destroy().catch(() => { });
291
+ throw proxySafeError(error);
292
+ }
293
+ }
122
294
  return new Promise((resolve, reject) => {
123
295
  const request = (url.protocol === "https:" ? httpsRequest : httpRequest)({
124
296
  protocol: url.protocol,
@@ -138,7 +310,15 @@ async function requestPinned(url, pinned, signal) {
138
310
  const headers = new Headers();
139
311
  for (let i = 0; i < body.rawHeaders.length; i += 2)
140
312
  headers.append(body.rawHeaders[i], body.rawHeaders[i + 1]);
141
- resolve({ status: body.statusCode ?? 0, headers, body });
313
+ resolve({
314
+ status: body.statusCode ?? 0,
315
+ headers,
316
+ body,
317
+ async release(discard = false) {
318
+ if (discard)
319
+ body.destroy();
320
+ },
321
+ });
142
322
  });
143
323
  request.once("error", reject);
144
324
  request.end();
@@ -354,12 +534,34 @@ function interrupted(label) {
354
534
  error.name = "AbortError";
355
535
  return error;
356
536
  }
357
- async function searchFetch(url, init, parentSignal) {
537
+ const searchProxyAgents = new Map();
538
+ function searchProxyAgent(uri) {
539
+ let agent = searchProxyAgents.get(uri);
540
+ if (!agent) {
541
+ agent = new ProxyAgent({ uri, proxyTunnel: true });
542
+ searchProxyAgents.set(uri, agent);
543
+ }
544
+ return agent;
545
+ }
546
+ async function searchFetch(url, init, parentSignal, cwd) {
358
547
  if (parentSignal?.aborted)
359
548
  throw interrupted("web search");
360
549
  const attemptSignal = AbortSignal.timeout(SEARCH_ATTEMPT_MS);
361
550
  const signal = parentSignal ? AbortSignal.any([parentSignal, attemptSignal]) : attemptSignal;
362
- return fetch(url, { ...init, signal });
551
+ const proxy = webProxy(new URL(url), cwd);
552
+ if (!proxy)
553
+ return fetch(url, { ...init, signal });
554
+ try {
555
+ const response = await undiciFetch(url, {
556
+ ...init,
557
+ dispatcher: searchProxyAgent(proxy.uri),
558
+ signal,
559
+ });
560
+ return response;
561
+ }
562
+ catch (error) {
563
+ throw proxySafeError(error);
564
+ }
363
565
  }
364
566
  async function firstSuccessfulSearch(attempts, failures, signal) {
365
567
  // Try one provider at a time. Search terms can be sensitive; a successful request must not be mirrored
@@ -422,7 +624,7 @@ registerTool({
422
624
  method: "POST",
423
625
  headers: { "content-type": "application/json" },
424
626
  body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
425
- }, ctx.signal);
627
+ }, ctx.signal, ctx.cwd);
426
628
  if (!res.ok)
427
629
  throw new Error(`HTTP ${res.status}`);
428
630
  const j = (await res.json());
@@ -437,7 +639,7 @@ registerTool({
437
639
  method: "GET",
438
640
  redirect: "follow",
439
641
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
440
- }, ctx.signal);
642
+ }, ctx.signal, ctx.cwd);
441
643
  if (!res.ok)
442
644
  throw new Error(`HTTP ${res.status}`);
443
645
  return parseBingSearchResults(await res.text(), limit);
@@ -456,7 +658,7 @@ registerTool({
456
658
  method: "GET",
457
659
  redirect: "follow",
458
660
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
459
- }, ctx.signal);
661
+ }, ctx.signal, ctx.cwd);
460
662
  if (!res.ok)
461
663
  throw new Error(`HTTP ${res.status}`);
462
664
  return parseBaiduSearchResults(await res.text(), limit);
@@ -469,7 +671,7 @@ registerTool({
469
671
  method: "GET",
470
672
  redirect: "follow",
471
673
  headers: { "user-agent": SEARCH_UA, accept: "text/html" },
472
- }, ctx.signal);
674
+ }, ctx.signal, ctx.cwd);
473
675
  if (!res.ok)
474
676
  throw new Error(`HTTP ${res.status}`);
475
677
  return parseGoogleSearchResults(await res.text(), limit);
@@ -483,7 +685,7 @@ registerTool({
483
685
  redirect: "follow",
484
686
  headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
485
687
  body: `q=${encodeURIComponent(q)}`,
486
- }, ctx.signal);
688
+ }, ctx.signal, ctx.cwd);
487
689
  if (!res.ok)
488
690
  throw new Error(`HTTP ${res.status}`);
489
691
  return parseSearchResults(await res.text(), limit);
@@ -518,10 +720,12 @@ registerTool({
518
720
  url = new URL(input.url);
519
721
  }
520
722
  catch {
521
- return `Error: invalid URL: ${input.url}`;
723
+ return "Error: invalid URL.";
522
724
  }
523
725
  if (url.protocol !== "http:" && url.protocol !== "https:")
524
726
  return "Error: only http/https URLs are supported.";
727
+ if (url.username || url.password)
728
+ return "Error: URL-embedded credentials are not supported.";
525
729
  const cap = Math.min(Math.max(1000, input.max_chars ?? MAX), 200_000);
526
730
  const ctrl = new AbortController();
527
731
  const timer = setTimeout(() => ctrl.abort(), 30_000);
@@ -536,24 +740,36 @@ registerTool({
536
740
  const pinned = await resolvePublicHost(current.hostname);
537
741
  if (ctx.signal?.aborted)
538
742
  throw interrupted("web fetch");
539
- res = await requestPinned(current, pinned, signal);
743
+ res = await requestPinned(current, pinned, signal, webProxy(current, ctx.cwd));
540
744
  const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
541
745
  if (!loc || hop >= 5)
542
746
  break;
543
747
  const next = new URL(loc, current);
544
748
  if (next.protocol !== "http:" && next.protocol !== "https:") {
545
- res.body.destroy();
749
+ await res.release(true);
546
750
  return "Error: redirect to a non-http(s) URL was blocked.";
547
751
  }
752
+ if (next.username || next.password) {
753
+ await res.release(true);
754
+ return "Error: redirect with URL-embedded credentials was blocked.";
755
+ }
548
756
  // We never consume redirect bodies. Destroy the socket before following the next pinned hop so a
549
757
  // server cannot accumulate idle response streams across a redirect chain.
550
- res.body.destroy();
758
+ await res.release(true);
551
759
  if (ctx.signal?.aborted)
552
760
  throw interrupted("web fetch");
553
761
  current = next;
554
762
  }
555
763
  const ct = res.headers.get("content-type") ?? "";
556
- const raw = await readPinnedCapped(res.body, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
764
+ let raw;
765
+ let bodyRead = false;
766
+ try {
767
+ raw = await readPinnedCapped(res.body, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
768
+ bodyRead = true;
769
+ }
770
+ finally {
771
+ await res.release(!bodyRead);
772
+ }
557
773
  if (ctx.signal?.aborted)
558
774
  throw interrupted("web fetch");
559
775
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
@@ -569,7 +785,10 @@ registerTool({
569
785
  catch (e) {
570
786
  if (ctx.signal?.aborted)
571
787
  throw interrupted("web fetch");
572
- return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
788
+ const display = new URL(url.href);
789
+ display.username = "";
790
+ display.password = "";
791
+ return `Error fetching ${display.href}: ${safeNetworkError(e)}`;
573
792
  }
574
793
  finally {
575
794
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.131.1",
3
+ "version": "0.132.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"
@@ -59,6 +59,7 @@
59
59
  "ink": "^6.8.0",
60
60
  "openai": "^6.44.0",
61
61
  "react": "^19.2.7",
62
+ "undici": "^7.28.0",
62
63
  "ws": "^8.19.0"
63
64
  },
64
65
  "devDependencies": {
@@ -77,6 +78,9 @@
77
78
  "@larksuiteoapi/node-sdk": {
78
79
  "axios": "1.18.1",
79
80
  "protobufjs": "7.6.5"
80
- }
81
+ },
82
+ "@hono/node-server": "2.0.11",
83
+ "hono": "4.12.31",
84
+ "fast-uri": "3.1.3"
81
85
  }
82
86
  }