@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.
- package/CHANGELOG.md +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- 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
|
-
//
|
|
3
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
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 (
|
|
23
|
-
return true; // link-local
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
282
|
-
//
|
|
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
|
|
318
|
-
//
|
|
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
|
|
398
|
-
res = await
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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.
|
|
28
|
-
await
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.122.1",
|
|
4
4
|
"description": "hara — a coding agent CLI that runs like an engineering org.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"hara": "
|
|
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": ">=
|
|
39
|
+
"node": ">=22.12.0"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
|
-
"build": "tsc",
|
|
42
|
-
"prepare": "
|
|
43
|
-
"dev": "tsx src/
|
|
44
|
-
"start": "node
|
|
45
|
-
"test": "
|
|
46
|
-
"build:binary": "
|
|
47
|
-
"build:binaries": "
|
|
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();
|