@nanhara/hara 0.121.1 → 0.122.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
package/dist/tools/web.js CHANGED
@@ -1,42 +1,148 @@
1
1
  // web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
2
- // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash) —
3
- // so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
4
- // redirect hop, and the body is read under a hard byte ceiling.
2
+ // NOT sandboxed (network egress is in-process, not via bash) — so it carries an SSRF guard: private/
3
+ // loopback/link-local targets are refused, the verified DNS address is pinned to the actual socket on
4
+ // every redirect hop (DNS-rebinding safe), and the body is read under a hard byte ceiling.
5
5
  import { registerTool } from "./registry.js";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
+ import { request as httpRequest } from "node:http";
9
+ import { request as httpsRequest } from "node:https";
8
10
  import { wrapUntrusted } from "../security/external-content.js";
9
11
  const MAX = 60_000;
10
12
  const SEARCH_ATTEMPT_MS = 8_000;
11
13
  const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
14
+ function ipv6Words(input) {
15
+ let value = input.toLowerCase();
16
+ const dotted = value.lastIndexOf(":") >= 0 ? value.slice(value.lastIndexOf(":") + 1) : "";
17
+ if (dotted.includes(".")) {
18
+ const octets = dotted.split(".").map(Number);
19
+ if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
20
+ return null;
21
+ value = `${value.slice(0, value.lastIndexOf(":") + 1)}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
22
+ }
23
+ const halves = value.split("::");
24
+ if (halves.length > 2)
25
+ return null;
26
+ const parse = (part) => {
27
+ if (!part)
28
+ return [];
29
+ const pieces = part.split(":");
30
+ if (pieces.some((piece) => !/^[0-9a-f]{1,4}$/.test(piece)))
31
+ return null;
32
+ return pieces.map((piece) => Number.parseInt(piece, 16));
33
+ };
34
+ const left = parse(halves[0]);
35
+ const right = parse(halves[1] ?? "");
36
+ if (!left || !right)
37
+ return null;
38
+ if (halves.length === 1)
39
+ return left.length === 8 ? left : null;
40
+ const zeros = 8 - left.length - right.length;
41
+ return zeros > 0 ? [...left, ...Array(zeros).fill(0), ...right] : null;
42
+ }
12
43
  /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
13
44
  export function isPrivateIp(ip) {
14
45
  const host = ip.replace(/^\[|\]$/g, "");
15
46
  if (isIP(host) === 4) {
16
47
  const p = host.split(".").map(Number);
17
- return p[0] === 0 || p[0] === 10 || p[0] === 127 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127);
48
+ return (p[0] === 0 || p[0] === 10 || p[0] === 127 ||
49
+ (p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
50
+ (p[0] === 169 && p[1] === 254) ||
51
+ (p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
52
+ (p[0] === 192 && p[1] === 0 && p[2] === 0 && p[3] !== 9 && p[3] !== 10) ||
53
+ (p[0] === 192 && p[1] === 0 && p[2] === 2) ||
54
+ (p[0] === 192 && p[1] === 88 && p[2] === 99) ||
55
+ (p[0] === 192 && p[1] === 168) ||
56
+ (p[0] === 198 && (p[1] === 18 || p[1] === 19)) ||
57
+ (p[0] === 198 && p[1] === 51 && p[2] === 100) ||
58
+ (p[0] === 203 && p[1] === 0 && p[2] === 113) ||
59
+ p[0] >= 224 // multicast + reserved/broadcast space is not a public unicast web destination
60
+ );
18
61
  }
19
62
  const l = host.toLowerCase();
20
- if (l === "::1" || l === "::")
63
+ const words = ipv6Words(l);
64
+ if (!words)
65
+ return false;
66
+ if (words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1)
21
67
  return true;
22
- if (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
23
- return true; // link-local + unique-local
24
- const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
25
- return m ? isPrivateIp(m[1]) : false;
68
+ if ((words[0] & 0xffc0) === 0xfe80)
69
+ return true; // fe80::/10 link-local (fe80..febf)
70
+ if ((words[0] & 0xfe00) === 0xfc00)
71
+ return true; // fc00::/7 unique-local
72
+ if ((words[0] & 0xffc0) === 0xfec0)
73
+ return true; // fec0::/10 deprecated site-local, still internal
74
+ // IPv4-mapped/compatible spellings are normalized by URL to hexadecimal (for example
75
+ // ::ffff:127.0.0.1 → ::ffff:7f00:1), so classify the embedded address from parsed words.
76
+ const mapped = words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
77
+ const compatible = words.slice(0, 6).every((word) => word === 0);
78
+ if (mapped || compatible) {
79
+ const v4 = `${words[6] >>> 8}.${words[6] & 0xff}.${words[7] >>> 8}.${words[7] & 0xff}`;
80
+ return isPrivateIp(v4);
81
+ }
82
+ // Native public IPv6 is global-unicast 2000::/3. Also reject special-use ranges that sit inside it:
83
+ // Teredo, benchmarking/ORCHID/documentation, and deprecated 6to4 transition addresses.
84
+ if ((words[0] & 0xe000) !== 0x2000)
85
+ return true;
86
+ if (words[0] === 0x2002)
87
+ return true; // 6to4 can tunnel an embedded non-public IPv4 target
88
+ if (words[0] === 0x2001) {
89
+ if (words[1] === 0x0000 || words[1] === 0x0002 || words[1] === 0x0db8)
90
+ return true;
91
+ if ((words[1] & 0xfff0) === 0x0010 || (words[1] & 0xfff0) === 0x0020)
92
+ return true;
93
+ }
94
+ return words[0] === 0x3fff && (words[1] & 0xf000) === 0x0000; // 3fff::/20 documentation
26
95
  }
27
- /** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
28
- * / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
29
- async function assertPublicHost(hostname) {
96
+ /** Resolve once, reject the hostname if ANY answer is internal, then return the exact public address the
97
+ * socket must use. Rejecting mixed public/private answers prevents round-robin records from becoming a
98
+ * probabilistic bypass. The optional resolver keeps the policy directly testable. */
99
+ export async function resolvePublicHost(hostname, resolver = lookup) {
30
100
  const host = hostname.replace(/^\[|\]$/g, "");
31
101
  if (isIP(host)) {
32
102
  if (isPrivateIp(host))
33
103
  throw new Error(`refusing to fetch ${host} (private/loopback address)`);
34
- return;
104
+ return { address: host, family: isIP(host) };
35
105
  }
36
- const addrs = await lookup(host, { all: true });
37
- for (const a of addrs)
106
+ const addrs = await resolver(host, { all: true });
107
+ if (!addrs.length)
108
+ throw new Error(`could not resolve ${host}`);
109
+ for (const a of addrs) {
110
+ const family = isIP(a.address);
111
+ if (!family || family !== a.family)
112
+ throw new Error(`resolver returned an invalid address for ${host}`);
38
113
  if (isPrivateIp(a.address))
39
114
  throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
115
+ }
116
+ const chosen = addrs[0];
117
+ return { address: chosen.address, family: chosen.family };
118
+ }
119
+ /** One HTTP hop whose TCP socket is pinned to the address approved above. `Host` and TLS SNI retain the
120
+ * original hostname, so virtual hosting/certificate checks work without a second DNS lookup. */
121
+ async function requestPinned(url, pinned, signal) {
122
+ return new Promise((resolve, reject) => {
123
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)({
124
+ protocol: url.protocol,
125
+ hostname: pinned.address,
126
+ family: pinned.family,
127
+ port: url.port || undefined,
128
+ path: `${url.pathname}${url.search}`,
129
+ method: "GET",
130
+ signal,
131
+ servername: url.hostname.replace(/^\[|\]$/g, ""),
132
+ headers: {
133
+ host: url.host,
134
+ "user-agent": "hara-cli",
135
+ accept: "text/html,text/plain,application/json,*/*",
136
+ },
137
+ }, (body) => {
138
+ const headers = new Headers();
139
+ for (let i = 0; i < body.rawHeaders.length; i += 2)
140
+ headers.append(body.rawHeaders[i], body.rawHeaders[i + 1]);
141
+ resolve({ status: body.statusCode ?? 0, headers, body });
142
+ });
143
+ request.once("error", reject);
144
+ request.end();
145
+ });
40
146
  }
41
147
  /** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
42
148
  async function readCapped(res, maxBytes) {
@@ -65,6 +171,25 @@ async function readCapped(res, maxBytes) {
65
171
  }
66
172
  return Buffer.concat(chunks).toString("utf8");
67
173
  }
174
+ async function readPinnedCapped(res, maxBytes) {
175
+ const chunks = [];
176
+ let total = 0;
177
+ for await (const value of res) {
178
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
179
+ const remaining = maxBytes - total;
180
+ if (remaining <= 0) {
181
+ res.destroy();
182
+ break;
183
+ }
184
+ chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk);
185
+ total += Math.min(chunk.length, remaining);
186
+ if (chunk.length >= remaining) {
187
+ res.destroy();
188
+ break;
189
+ }
190
+ }
191
+ return Buffer.concat(chunks).toString("utf8");
192
+ }
68
193
  /** Strip HTML to a readable-ish plain-text approximation (no dependency). */
69
194
  export function htmlToText(html) {
70
195
  return html
@@ -228,34 +353,22 @@ async function searchFetch(url, init) {
228
353
  return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
229
354
  }
230
355
  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
- });
356
+ // Try one provider at a time. Search terms can be sensitive; a successful request must not be mirrored
357
+ // to unrelated providers merely to save a few seconds, and sequential fallback also avoids leaving
358
+ // losing requests running after the tool has already returned.
359
+ for (const attempt of attempts) {
360
+ try {
361
+ const results = await attempt.run();
362
+ if (results.length)
363
+ return results;
364
+ failures.push(`${attempt.name} no results`);
257
365
  }
258
- });
366
+ catch (e) {
367
+ const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
368
+ failures.push(`${attempt.name} ${reason}`);
369
+ }
370
+ }
371
+ return null;
259
372
  }
260
373
  registerTool({
261
374
  name: "web_search",
@@ -278,8 +391,8 @@ registerTool({
278
391
  const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
279
392
  const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
280
393
  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.
394
+ // Prefer the explicitly configured agent-search API. Only disclose the query to another provider if
395
+ // that request fails or returns no results; without a key, start with mainland-accessible Bing CN.
283
396
  const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
284
397
  const primaryAttempts = [];
285
398
  if (key) {
@@ -314,8 +427,8 @@ registerTool({
314
427
  const primary = await firstSuccessfulSearch(primaryAttempts, failures);
315
428
  if (primary)
316
429
  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).
430
+ // Secondary sources are ordered fallbacks. Google is included where reachable, but is never the sole
431
+ // path because mainland networks commonly receive a challenge or JavaScript shell.
319
432
  const secondary = await firstSuccessfulSearch([
320
433
  {
321
434
  name: "Baidu",
@@ -394,22 +507,23 @@ registerTool({
394
507
  let current = url;
395
508
  let res;
396
509
  for (let hop = 0;; hop++) {
397
- await assertPublicHost(current.hostname);
398
- res = await fetch(current, {
399
- signal: ctrl.signal,
400
- redirect: "manual",
401
- headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
402
- });
510
+ const pinned = await resolvePublicHost(current.hostname);
511
+ res = await requestPinned(current, pinned, ctrl.signal);
403
512
  const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
404
513
  if (!loc || hop >= 5)
405
514
  break;
406
515
  const next = new URL(loc, current);
407
- if (next.protocol !== "http:" && next.protocol !== "https:")
516
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
517
+ res.body.destroy();
408
518
  return "Error: redirect to a non-http(s) URL was blocked.";
519
+ }
520
+ // We never consume redirect bodies. Destroy the socket before following the next pinned hop so a
521
+ // server cannot accumulate idle response streams across a redirect chain.
522
+ res.body.destroy();
409
523
  current = next;
410
524
  }
411
525
  const ct = res.headers.get("content-type") ?? "";
412
- const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
526
+ const raw = await readPinnedCapped(res.body, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
413
527
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
414
528
  if (text.length > cap)
415
529
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
package/dist/undo.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // In-session undo stack for file changes. Each edit tool records the prior state of the files it
2
2
  // touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
3
- import { unlink } from "node:fs/promises";
4
- import { atomicWriteText } from "./fs-write.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, join } from "node:path";
5
+ import { linkSync, lstatSync, readlinkSync, symlinkSync } from "node:fs";
6
+ import { rename, symlink, unlink } from "node:fs/promises";
7
+ import { atomicWriteText, discardClaimedPath, FileChangedError, removeCreatedDirectories } from "./fs-write.js";
8
+ import { readRegularFileSnapshotNoFollow } from "./fs-read.js";
5
9
  import { invalidateFileCandidates } from "./context/mentions.js";
6
10
  const stack = [];
7
11
  const MAX = 50;
@@ -16,27 +20,99 @@ export function recordEdit(group) {
16
20
  export function undoDepth() {
17
21
  return stack.length;
18
22
  }
23
+ async function restoreMovedFile(quarantine, target) {
24
+ const info = lstatSync(quarantine);
25
+ // link(2) may follow a symlink source on some platforms. Recreate its topology explicitly while retaining
26
+ // create-if-absent semantics; a regular inode can still use an atomic hard link.
27
+ const linkTarget = info.isSymbolicLink() ? readlinkSync(quarantine) : undefined;
28
+ if (linkTarget !== undefined)
29
+ symlinkSync(linkTarget, target);
30
+ else
31
+ linkSync(quarantine, target);
32
+ discardClaimedPath(quarantine, {
33
+ dev: info.dev,
34
+ ino: info.ino,
35
+ mode: info.mode & 0o777,
36
+ nlink: info.nlink + (linkTarget === undefined ? 1 : 0),
37
+ linkTarget,
38
+ });
39
+ }
40
+ /** Undo an update/create only if the path still names the exact inode and bytes written by that tool. */
41
+ async function undoCommitted(s) {
42
+ if (!s.committed || s.after === undefined)
43
+ throw new Error(`missing committed snapshot for ${s.path}`);
44
+ const target = s.committed.target;
45
+ const quarantine = join(dirname(target), `.hara-undo-${process.pid}-${randomUUID()}.tmp`);
46
+ await rename(target, quarantine);
47
+ try {
48
+ // O_NOFOLLOW + same-fd fstat/read rejects a path replaced with a symlink to the committed inode.
49
+ const current = await readRegularFileSnapshotNoFollow(quarantine);
50
+ if (current.dev !== s.committed.dev ||
51
+ current.ino !== s.committed.ino ||
52
+ current.mode !== s.committed.mode ||
53
+ current.nlink !== s.committed.nlink ||
54
+ current.text !== s.after) {
55
+ await restoreMovedFile(quarantine, target);
56
+ throw new FileChangedError(s.path);
57
+ }
58
+ if (s.before === null) {
59
+ discardClaimedPath(quarantine, s.committed);
60
+ await removeCreatedDirectories(s.committed.createdDirs);
61
+ return;
62
+ }
63
+ await atomicWriteText(target, s.before, { expected: null, mode: s.beforeMode });
64
+ discardClaimedPath(quarantine, s.committed);
65
+ }
66
+ catch (error) {
67
+ // If restoration failed after the owned inode was moved, put it back only when the destination is still
68
+ // absent. link(2) never overwrites a concurrent file; leave quarantine for recovery if another appeared.
69
+ try {
70
+ await restoreMovedFile(quarantine, target);
71
+ }
72
+ catch {
73
+ /* the original error remains the useful user-facing failure */
74
+ }
75
+ throw error;
76
+ }
77
+ }
19
78
  /** Restore the most recent edit group. Returns the files reverted, or an error. */
20
79
  export async function undoLast() {
21
80
  const group = stack.pop();
22
81
  if (!group)
23
82
  return { error: "nothing to undo" };
24
83
  const files = [];
84
+ const failures = [];
25
85
  for (const s of group) {
26
86
  try {
27
- if (s.before === null) {
28
- await unlink(s.absPath).catch(() => { }); // was newly created → remove
87
+ if (s.committed && s.after !== undefined) {
88
+ await undoCommitted(s);
89
+ }
90
+ else if (s.before === null) {
91
+ try {
92
+ await unlink(s.absPath); // legacy snapshot: was newly created → remove
93
+ }
94
+ catch (error) {
95
+ if (error?.code !== "ENOENT")
96
+ throw error;
97
+ }
98
+ }
99
+ else if (s.linkTarget !== undefined) {
100
+ await symlink(s.linkTarget, s.absPath); // atomic create-if-absent; never clobbers a newer path
29
101
  }
30
102
  else {
31
- await atomicWriteText(s.absPath, s.before);
103
+ await atomicWriteText(s.absPath, s.before, { expected: s.removed ? null : undefined, mode: s.beforeMode });
32
104
  }
33
105
  files.push(s.path);
34
106
  }
35
- catch {
36
- /* skip a file we can't restore */
107
+ catch (error) {
108
+ failures.push(`${s.path}: ${error?.message ?? String(error)}`);
37
109
  }
38
110
  }
39
111
  if (files.length)
40
112
  invalidateFileCandidates();
113
+ if (failures.length) {
114
+ const prefix = files.length ? `partially reverted ${files.join(", ")}; ` : "";
115
+ return { error: `${prefix}could not safely undo ${failures.join("; ")}` };
116
+ }
41
117
  return { files };
42
118
  }
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.121.1",
3
+ "version": "0.122.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
- "hara": "dist/index.js"
6
+ "hara": "runtime-bootstrap.cjs"
7
7
  },
8
8
  "type": "module",
9
9
  "files": [
10
10
  "dist",
11
11
  "!dist/bin",
12
+ "runtime-bootstrap.cjs",
12
13
  "README.md",
13
14
  "CHANGELOG.md",
14
15
  "SECURITY.md",
@@ -35,16 +36,16 @@
35
36
  "url": "git+https://github.com/hara-cli/hara.git"
36
37
  },
37
38
  "engines": {
38
- "node": ">=20"
39
+ "node": ">=22.12.0"
39
40
  },
40
41
  "scripts": {
41
- "build": "tsc",
42
- "prepare": "tsc",
43
- "dev": "tsx src/index.ts",
44
- "start": "node dist/index.js",
45
- "test": "tsc && node --test test/*.test.mjs",
46
- "build:binary": "tsc && bun scripts/build-binary.ts dist/bin/hara",
47
- "build:binaries": "tsc && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
42
+ "build": "tsc && node scripts/normalize-dist-modes.mjs",
43
+ "prepare": "npm run build",
44
+ "dev": "tsx src/cli.ts",
45
+ "start": "node runtime-bootstrap.cjs",
46
+ "test": "npm run build && node --test test/*.test.mjs",
47
+ "build:binary": "npm run build && bun scripts/build-binary.ts dist/bin/hara",
48
+ "build:binaries": "npm run build && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
48
49
  },
49
50
  "publishConfig": {
50
51
  "access": "public"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // This file is the npm/Docker entry point. Keep it dependency-free and parseable by old Node releases so
5
+ // users get an upgrade instruction instead of a SyntaxError from Hara's ESM output or its dependencies.
6
+ var MIN_NODE_MAJOR = 22;
7
+ var MIN_NODE_VERSION = "22.12.0";
8
+
9
+ function supportedNodeVersion(version) {
10
+ var match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
11
+ if (!match) return false;
12
+ var current = [Number(match[1]), Number(match[2]), Number(match[3])];
13
+ var minimum = MIN_NODE_VERSION.split(".").map(Number);
14
+ for (var index = 0; index < minimum.length; index += 1) {
15
+ if (current[index] > minimum[index]) return true;
16
+ if (current[index] < minimum[index]) return false;
17
+ }
18
+ return true;
19
+ }
20
+
21
+ function unsupportedNodeMessage(versions) {
22
+ versions = versions || process.versions;
23
+ if (versions.bun) return null;
24
+
25
+ var version = String(versions.node || "unknown");
26
+ if (supportedNodeVersion(version)) return null;
27
+ var major = Number.parseInt(version, 10);
28
+ var detail = major === MIN_NODE_MAJOR
29
+ ? "This Node.js 22 release is below Hara's supported " + MIN_NODE_VERSION + " floor."
30
+ : "This Node.js release is below Hara's supported " + MIN_NODE_VERSION + " floor.";
31
+ return [
32
+ "Hara requires Node.js " + MIN_NODE_VERSION + " or newer (detected " + version + ").",
33
+ detail,
34
+ "Upgrade with: nvm install " + MIN_NODE_MAJOR + " && nvm use " + MIN_NODE_MAJOR,
35
+ "Or install the standalone Hara binary, which does not require Node.js:",
36
+ " curl -fsSL https://raw.githubusercontent.com/hara-cli/hara/main/install.sh | sh",
37
+ ].join("\n");
38
+ }
39
+
40
+ function failStart(error) {
41
+ var message = error && error.message ? error.message : String(error);
42
+ process.stderr.write("hara: failed to start: " + message + "\n");
43
+ process.exitCode = 1;
44
+ }
45
+
46
+ function main() {
47
+ var runtimeError = unsupportedNodeMessage(process.versions);
48
+ if (runtimeError) {
49
+ process.stderr.write(runtimeError + "\n");
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ try {
55
+ // Keeping import() inside a string prevents legacy parsers from seeing unsupported ESM syntax. This
56
+ // branch is reached only on the supported Node floor (or Bun when used as a script runtime).
57
+ var load = Function("specifier", "return import(specifier)");
58
+ var entry = require("url").pathToFileURL(require("path").join(__dirname, "dist", "index.js")).href;
59
+ load(entry).catch(failStart);
60
+ } catch (error) {
61
+ failStart(error);
62
+ }
63
+ }
64
+
65
+ module.exports = {
66
+ MIN_NODE_MAJOR: MIN_NODE_MAJOR,
67
+ MIN_NODE_VERSION: MIN_NODE_VERSION,
68
+ supportedNodeVersion: supportedNodeVersion,
69
+ unsupportedNodeMessage: unsupportedNodeMessage,
70
+ };
71
+
72
+ if (require.main === module) main();