@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
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// privileged tool — and because fan-out sub-agents only get the read-only allow-list (READONLY_TOOLS), they
|
|
8
8
|
// never see this tool. Trust tiers (off|gated|full) gate the dangerous bypass/full-access sub-modes.
|
|
9
9
|
import { spawn, execFileSync } from "node:child_process";
|
|
10
|
+
import { platform } from "node:os";
|
|
10
11
|
import { registerTool } from "./registry.js";
|
|
11
12
|
import { capHeadTail } from "./builtin.js";
|
|
12
13
|
import { loadConfig } from "../config.js";
|
|
14
|
+
import { createToolOutputLineRedactor, redactToolSubprocessOutput, terminateSubprocessTree, toolSubprocessEnv, } from "../security/subprocess-env.js";
|
|
13
15
|
/** Pure: build the spawn (cmd, args) for a backend, or null if unknown. Maps hara's sandbox/trust → the agent's
|
|
14
16
|
* own permission flags; the dangerous bypass/full-access modes are only reachable at trust "full". */
|
|
15
17
|
export function buildExternalArgv(backend, task, o) {
|
|
@@ -24,6 +26,20 @@ export function buildExternalArgv(backend, task, o) {
|
|
|
24
26
|
return null;
|
|
25
27
|
}
|
|
26
28
|
const BUILTIN_BACKENDS = ["claude", "codex"];
|
|
29
|
+
const EXTERNAL_CAPTURE_PER_STREAM = 2 * 1024 * 1024;
|
|
30
|
+
const EXTERNAL_OUTPUT_KILL_LIMIT = 4 * 1024 * 1024;
|
|
31
|
+
export function appendBoundedExternalOutput(current, chunk, limit = EXTERNAL_CAPTURE_PER_STREAM) {
|
|
32
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
33
|
+
return "";
|
|
34
|
+
const combined = current + chunk;
|
|
35
|
+
if (combined.length <= limit)
|
|
36
|
+
return combined;
|
|
37
|
+
const marker = "\n…[external output truncated]…\n";
|
|
38
|
+
const retained = Math.max(0, limit - marker.length);
|
|
39
|
+
const head = Math.floor(retained / 4);
|
|
40
|
+
const tail = retained - head;
|
|
41
|
+
return combined.slice(0, head) + marker.slice(0, limit - head - tail) + (tail ? combined.slice(-tail) : "");
|
|
42
|
+
}
|
|
27
43
|
/** Probe a CLI's availability via `<bin> --version` (cached per process). */
|
|
28
44
|
const availCache = new Map();
|
|
29
45
|
function available(bin) {
|
|
@@ -31,7 +47,7 @@ function available(bin) {
|
|
|
31
47
|
return availCache.get(bin);
|
|
32
48
|
let ok = false;
|
|
33
49
|
try {
|
|
34
|
-
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
50
|
+
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000, env: toolSubprocessEnv() });
|
|
35
51
|
ok = true;
|
|
36
52
|
}
|
|
37
53
|
catch {
|
|
@@ -52,6 +68,7 @@ registerTool({
|
|
|
52
68
|
"another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
|
|
53
69
|
"Args: task (required), backend (claude|codex; default = first installed), model (optional).",
|
|
54
70
|
kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
|
|
71
|
+
trustBoundary: "external",
|
|
55
72
|
input_schema: {
|
|
56
73
|
type: "object",
|
|
57
74
|
properties: {
|
|
@@ -63,6 +80,10 @@ registerTool({
|
|
|
63
80
|
required: ["task"],
|
|
64
81
|
},
|
|
65
82
|
async run(input, ctx) {
|
|
83
|
+
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
84
|
+
return ("Blocked: external_agent runs another host coding agent outside Hara's protected-file boundary. " +
|
|
85
|
+
"Use it interactively after approval, or restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that agent.");
|
|
86
|
+
}
|
|
66
87
|
const trust = resolveTrust();
|
|
67
88
|
if (trust === "off")
|
|
68
89
|
return "external_agent is disabled (set externalAgentTrust to gated|full, or HARA_EXTERNAL_AGENT_TRUST).";
|
|
@@ -80,37 +101,110 @@ registerTool({
|
|
|
80
101
|
return `Unknown backend '${backend}'.`;
|
|
81
102
|
const timeout = Math.min(Math.max(30_000, Number(input.timeout_ms) || 600_000), 1_800_000);
|
|
82
103
|
return await new Promise((resolve) => {
|
|
83
|
-
const
|
|
104
|
+
const processGroup = platform() !== "win32";
|
|
105
|
+
let child;
|
|
106
|
+
try {
|
|
107
|
+
child = spawn(built.cmd, built.args, {
|
|
108
|
+
cwd: ctx.cwd,
|
|
109
|
+
env: toolSubprocessEnv(),
|
|
110
|
+
detached: processGroup,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
resolve(redactToolSubprocessOutput(`[${backend}] failed to start: ${error instanceof Error ? error.message : String(error)}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
84
117
|
let out = "";
|
|
85
118
|
let err = "";
|
|
119
|
+
let outputBytes = 0;
|
|
86
120
|
let done = false;
|
|
121
|
+
let stoppingReason = null;
|
|
122
|
+
let forceIssued = false;
|
|
123
|
+
let closeBeforeForce = false;
|
|
124
|
+
let cancelTermination;
|
|
125
|
+
const liveOut = ctx.ui ? createToolOutputLineRedactor((line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))) : null;
|
|
126
|
+
const liveErr = ctx.ui ? createToolOutputLineRedactor((line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))) : null;
|
|
87
127
|
const finish = (s) => {
|
|
88
128
|
if (done)
|
|
89
129
|
return;
|
|
90
130
|
done = true;
|
|
91
131
|
clearTimeout(timer);
|
|
92
|
-
|
|
132
|
+
// A stopped tree must still receive its scheduled group KILL even if the direct shell closed first.
|
|
133
|
+
// The default cancellation removes only the wall-clock fallback, not the force timer.
|
|
134
|
+
cancelTermination?.();
|
|
135
|
+
liveOut?.flush();
|
|
136
|
+
liveErr?.flush();
|
|
137
|
+
resolve(capHeadTail(redactToolSubprocessOutput(s)));
|
|
138
|
+
};
|
|
139
|
+
const stoppedText = () => {
|
|
140
|
+
const text = out.trim() || "(external agent produced no output)";
|
|
141
|
+
return `${stoppingReason}\n${text}${err ? `\n[stderr]\n${err.trim()}` : ""}`;
|
|
142
|
+
};
|
|
143
|
+
const stopTree = (reason) => {
|
|
144
|
+
if (done || stoppingReason)
|
|
145
|
+
return;
|
|
146
|
+
stoppingReason = reason;
|
|
147
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
148
|
+
processGroup,
|
|
149
|
+
graceMs: 2_000,
|
|
150
|
+
fallbackMs: 3_000,
|
|
151
|
+
onForce: () => {
|
|
152
|
+
forceIssued = true;
|
|
153
|
+
if (closeBeforeForce)
|
|
154
|
+
finish(stoppedText());
|
|
155
|
+
},
|
|
156
|
+
onFallback: () => {
|
|
157
|
+
// A deliberately daemonized descendant may retain the pipes after escaping the owned group.
|
|
158
|
+
child.stdout?.destroy();
|
|
159
|
+
child.stderr?.destroy();
|
|
160
|
+
finish(stoppedText());
|
|
161
|
+
},
|
|
162
|
+
});
|
|
93
163
|
};
|
|
94
164
|
const timer = setTimeout(() => {
|
|
95
|
-
|
|
96
|
-
|
|
165
|
+
stopTree(`[${backend}] timed out after ${timeout}ms`);
|
|
166
|
+
}, timeout);
|
|
167
|
+
child.stdin?.on("error", () => { });
|
|
168
|
+
child.stdin?.end(); // task is passed via argv
|
|
169
|
+
child.stdout?.on("data", (d) => {
|
|
170
|
+
if (done || stoppingReason)
|
|
171
|
+
return;
|
|
172
|
+
const text = d.toString();
|
|
173
|
+
outputBytes += d.length;
|
|
174
|
+
out = appendBoundedExternalOutput(out, text);
|
|
175
|
+
liveOut?.push(text);
|
|
176
|
+
if (outputBytes > EXTERNAL_OUTPUT_KILL_LIMIT) {
|
|
177
|
+
stopTree(`[${backend}] stopped after exceeding ${EXTERNAL_OUTPUT_KILL_LIMIT} output bytes`);
|
|
97
178
|
}
|
|
98
|
-
|
|
99
|
-
|
|
179
|
+
});
|
|
180
|
+
child.stderr?.on("data", (d) => {
|
|
181
|
+
if (done || stoppingReason)
|
|
182
|
+
return;
|
|
183
|
+
const text = d.toString();
|
|
184
|
+
outputBytes += d.length;
|
|
185
|
+
err = appendBoundedExternalOutput(err, text);
|
|
186
|
+
liveErr?.push(text);
|
|
187
|
+
if (outputBytes > EXTERNAL_OUTPUT_KILL_LIMIT) {
|
|
188
|
+
stopTree(`[${backend}] stopped after exceeding ${EXTERNAL_OUTPUT_KILL_LIMIT} output bytes`);
|
|
100
189
|
}
|
|
101
|
-
finish(`[${backend}] timed out after ${timeout}ms\n${out}`);
|
|
102
|
-
}, timeout);
|
|
103
|
-
child.stdin.end(); // task is passed via argv
|
|
104
|
-
child.stdout.on("data", (d) => {
|
|
105
|
-
out += d.toString();
|
|
106
|
-
ctx.ui?.notice?.(d.toString().trimEnd());
|
|
107
190
|
});
|
|
108
|
-
child.
|
|
109
|
-
|
|
191
|
+
child.on("error", (e) => {
|
|
192
|
+
if (stoppingReason && !forceIssued) {
|
|
193
|
+
closeBeforeForce = true;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
finish(stoppingReason ? stoppedText() : `[${backend}] failed to start: ${e.message} (is it installed?)`);
|
|
110
197
|
});
|
|
111
|
-
child.on("error", (e) => finish(`[${backend}] failed to start: ${e.message} (is it installed?)`));
|
|
112
198
|
child.on("close", (code) => {
|
|
113
199
|
const text = out.trim() || "(external agent produced no output)";
|
|
200
|
+
if (stoppingReason) {
|
|
201
|
+
if (!forceIssued) {
|
|
202
|
+
closeBeforeForce = true;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
finish(stoppedText());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
114
208
|
finish(code === 0 ? text : `[${backend} exit ${code}]\n${text}${err ? `\n[stderr]\n${err.trim()}` : ""}`);
|
|
115
209
|
});
|
|
116
210
|
});
|
package/dist/tools/memory.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// Memory tools — the agent's interface to durable memory. memory_search/get are read-only
|
|
2
2
|
// (parallel-safe, never prompt); memory_write/forget are edits (gated by the approval mode).
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, resolve, join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { searchAssets, assetSearchRoots } from "../recall.js";
|
|
7
7
|
import { searchHybrid } from "../search/hybrid.js";
|
|
8
8
|
import { memoryRoots, appendMemory, replaceMemory, forgetMemory } from "../memory/store.js";
|
|
9
9
|
import { scanMemory, redactSecrets, scrubLocal } from "../memory/guard.js";
|
|
10
10
|
import { globalSkillsDir, skillsDir, invalidateSkillsCache } from "../skills/skills.js";
|
|
11
|
+
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
12
|
+
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
13
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
11
14
|
const asTarget = (v) => (["memory", "user", "log"].includes(v) ? v : "memory");
|
|
12
15
|
const asScope = (v) => (v === "global" ? "global" : "project");
|
|
13
16
|
registerTool({
|
|
@@ -34,12 +37,18 @@ registerTool({
|
|
|
34
37
|
kind: "read",
|
|
35
38
|
async run(input, ctx) {
|
|
36
39
|
const p = isAbsolute(String(input.path)) ? String(input.path) : resolve(ctx.cwd, String(input.path));
|
|
37
|
-
if (!memoryRoots(ctx.cwd).some((
|
|
40
|
+
if (!memoryRoots(ctx.cwd).some((root) => {
|
|
41
|
+
const rel = relative(resolve(root), p);
|
|
42
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
43
|
+
}))
|
|
38
44
|
return `Error: ${input.path} is outside the memory store.`;
|
|
45
|
+
const denied = sensitiveFileError(p, "read");
|
|
46
|
+
if (denied)
|
|
47
|
+
return denied;
|
|
39
48
|
if (!existsSync(p))
|
|
40
49
|
return `Error: no memory file at ${p}.`;
|
|
41
50
|
try {
|
|
42
|
-
return
|
|
51
|
+
return readModelContextFileSync(p, 64 * 1024).slice(0, 50_000);
|
|
43
52
|
}
|
|
44
53
|
catch (e) {
|
|
45
54
|
return `Error: ${e.message}`;
|
|
@@ -70,7 +79,9 @@ registerTool({
|
|
|
70
79
|
return `Blocked: this looks unsafe to store (${scan.hits.join(", ")}). Rephrase without secrets/injection text.`;
|
|
71
80
|
const scope = asScope(input.scope);
|
|
72
81
|
const target = asTarget(input.target);
|
|
73
|
-
const f = input.mode === "replace"
|
|
82
|
+
const f = input.mode === "replace"
|
|
83
|
+
? await replaceMemory(scope, target, content, ctx.cwd)
|
|
84
|
+
: await appendMemory(scope, target, content, ctx.cwd);
|
|
74
85
|
return `Saved to ${f}`;
|
|
75
86
|
},
|
|
76
87
|
});
|
|
@@ -117,8 +128,27 @@ registerTool({
|
|
|
117
128
|
const f = join(dir, "SKILL.md");
|
|
118
129
|
// dedup: surface a near-duplicate so the agent updates instead of piling up (lexical signal, not a block)
|
|
119
130
|
const dups = searchAssets(`${slug} ${description}`, 3, assetSearchRoots(ctx.cwd)).filter((h) => h.path !== f && h.score >= 2);
|
|
120
|
-
|
|
121
|
-
|
|
131
|
+
const skillText = `---\nname: ${slug}\ndescription: ${description}\n---\n\n${body.trim()}\n`;
|
|
132
|
+
let boundary;
|
|
133
|
+
let snapshot = null;
|
|
134
|
+
try {
|
|
135
|
+
boundary = bindAtomicWritePath(f, "create skill");
|
|
136
|
+
try {
|
|
137
|
+
snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "create skill");
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error?.code !== "ENOENT")
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
await atomicWriteText(boundary.target, skillText, {
|
|
144
|
+
expected: snapshot?.text ?? null,
|
|
145
|
+
expectedIdentity: snapshot ?? undefined,
|
|
146
|
+
boundary,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return `Error: cannot save skill ${slug}: ${error?.message ?? String(error)} No changes written.`;
|
|
151
|
+
}
|
|
122
152
|
invalidateSkillsCache();
|
|
123
153
|
const notes = [
|
|
124
154
|
redactions.length ? `redacted ${redactions.length} secret(s)` : "",
|
|
@@ -141,7 +171,7 @@ registerTool({
|
|
|
141
171
|
},
|
|
142
172
|
kind: "edit",
|
|
143
173
|
async run(input, ctx) {
|
|
144
|
-
const n = forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
|
|
174
|
+
const n = await forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
|
|
145
175
|
return n ? `Removed ${n} line(s).` : "(no matching lines)";
|
|
146
176
|
},
|
|
147
177
|
});
|
package/dist/tools/patch.js
CHANGED
|
@@ -1,13 +1,164 @@
|
|
|
1
1
|
// apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
|
|
2
2
|
// computed in memory first; nothing is written unless every change applies cleanly.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { linkSync, lstatSync, readlinkSync, renameSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { lstat, readlink, rename } from "node:fs/promises";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
7
|
import { registerTool } from "./registry.js";
|
|
6
8
|
import { applyEdits } from "./apply-core.js";
|
|
7
9
|
import { emitDiff } from "../diff.js";
|
|
8
10
|
import { recordEdit } from "../undo.js";
|
|
9
|
-
import { atomicWriteText, FileChangedError } from "../fs-write.js";
|
|
11
|
+
import { atomicWriteText, bindAtomicParentEntryPath, bindAtomicWritePath, discardClaimedPath, FileChangedError, removeCreatedDirectories, verifyAtomicWriteBoundary, } from "../fs-write.js";
|
|
10
12
|
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
13
|
+
import { readRegularFileSnapshot, readRegularFileSnapshotNoFollow, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, } from "../fs-read.js";
|
|
14
|
+
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
15
|
+
async function restoreMovedFile(quarantine, target) {
|
|
16
|
+
let identity;
|
|
17
|
+
try {
|
|
18
|
+
// Keep source inspection + create-if-absent in one JS turn. The subsequent discard performs another
|
|
19
|
+
// synchronous identity claim, so a watcher cannot turn cleanup into an unlink of an unrelated entry.
|
|
20
|
+
const info = lstatSync(quarantine);
|
|
21
|
+
const linkTarget = info.isSymbolicLink() ? readlinkSync(quarantine) : undefined;
|
|
22
|
+
if (linkTarget !== undefined)
|
|
23
|
+
symlinkSync(linkTarget, target);
|
|
24
|
+
else
|
|
25
|
+
linkSync(quarantine, target);
|
|
26
|
+
identity = {
|
|
27
|
+
dev: info.dev,
|
|
28
|
+
ino: info.ino,
|
|
29
|
+
mode: info.mode & 0o777,
|
|
30
|
+
nlink: info.nlink + (linkTarget === undefined ? 1 : 0),
|
|
31
|
+
linkTarget,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error?.code === "EEXIST") {
|
|
36
|
+
throw new Error(`another file appeared at ${target}; the concurrently moved file is preserved at ${quarantine}`);
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
discardClaimedPath(quarantine, identity);
|
|
41
|
+
}
|
|
42
|
+
/** Roll back a file we wrote without read→unlink/rename TOCTOU. First atomically move whichever inode is
|
|
43
|
+
* currently at the path aside, then inspect that moved inode. A concurrent replacement is restored with
|
|
44
|
+
* create-if-absent and never deleted/overwritten. */
|
|
45
|
+
async function rollbackWrittenPlan(plan) {
|
|
46
|
+
if (!plan.committed || plan.after === null)
|
|
47
|
+
throw new Error(`missing commit identity for ${plan.path}`);
|
|
48
|
+
const target = plan.committed.target;
|
|
49
|
+
const quarantine = join(dirname(target), `.hara-rollback-${process.pid}-${randomUUID()}.tmp`);
|
|
50
|
+
// atomicWriteText follows a destination symlink and replaces its real target. Roll back that exact returned
|
|
51
|
+
// target path; moving plan.abs would destroy the symlink while leaving the changed target untouched.
|
|
52
|
+
await rename(target, quarantine);
|
|
53
|
+
let current;
|
|
54
|
+
try {
|
|
55
|
+
current = await readRegularFileSnapshotNoFollow(quarantine);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
await restoreMovedFile(quarantine, target);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const owned = current.dev === plan.committed.dev && current.ino === plan.committed.ino && current.mode === plan.committed.mode && current.nlink === plan.committed.nlink && current.text === plan.after;
|
|
62
|
+
if (!owned) {
|
|
63
|
+
await restoreMovedFile(quarantine, target);
|
|
64
|
+
throw new FileChangedError(plan.path);
|
|
65
|
+
}
|
|
66
|
+
if (plan.type === "create") {
|
|
67
|
+
discardClaimedPath(quarantine, plan.committed);
|
|
68
|
+
await removeCreatedDirectories(plan.committed.createdDirs);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (plan.beforeMode === undefined) {
|
|
72
|
+
await restoreMovedFile(quarantine, target);
|
|
73
|
+
throw new Error(`missing preflight mode for ${plan.path}`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await atomicWriteText(target, plan.before, { expected: null, mode: plan.beforeMode });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
try {
|
|
80
|
+
await restoreMovedFile(quarantine, target);
|
|
81
|
+
}
|
|
82
|
+
catch (restoreError) {
|
|
83
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; could not restore quarantine: ${restoreError?.message ?? String(restoreError)}`);
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
discardClaimedPath(quarantine, plan.committed);
|
|
88
|
+
}
|
|
89
|
+
async function quarantineExpectedDelete(plan) {
|
|
90
|
+
if (!plan.beforeIdentity || !plan.beforePathIdentity || plan.beforeMode === undefined)
|
|
91
|
+
throw new Error(`missing preflight identity for ${plan.path}`);
|
|
92
|
+
if (!plan.writeBoundary)
|
|
93
|
+
throw new Error(`missing parent boundary for ${plan.path}`);
|
|
94
|
+
// Keep the initially moved path private until it has been verified. A directory watcher must not be able
|
|
95
|
+
// to mistake an unverified staging entry for the durable delete quarantine used by the commit/cleanup
|
|
96
|
+
// phases. Recording it immediately also lets the outer rollback recover (or accurately report) a failure
|
|
97
|
+
// that happens after rename but before verification completes.
|
|
98
|
+
const staging = join(dirname(plan.abs), `.hara-stage-delete-${process.pid}-${randomUUID()}.tmp`);
|
|
99
|
+
verifyAtomicWriteBoundary(plan.writeBoundary);
|
|
100
|
+
renameSync(plan.abs, staging);
|
|
101
|
+
plan.quarantine = staging;
|
|
102
|
+
const movedPath = await lstat(staging);
|
|
103
|
+
const samePath = movedPath.dev === plan.beforePathIdentity.dev && movedPath.ino === plan.beforePathIdentity.ino && (movedPath.mode & 0o777) === plan.beforePathIdentity.mode && movedPath.nlink === plan.beforePathIdentity.nlink;
|
|
104
|
+
const isExpectedLink = plan.beforeLinkTarget !== undefined;
|
|
105
|
+
const sameLink = movedPath.isSymbolicLink() === isExpectedLink && (!isExpectedLink || await readlink(staging) === plan.beforeLinkTarget);
|
|
106
|
+
if (!samePath || !sameLink)
|
|
107
|
+
throw new FileChangedError(plan.path);
|
|
108
|
+
const moved = isExpectedLink
|
|
109
|
+
? await readRegularFileSnapshot(staging)
|
|
110
|
+
: await readRegularFileSnapshotNoFollow(staging);
|
|
111
|
+
if (moved.dev !== plan.beforeIdentity.dev || moved.ino !== plan.beforeIdentity.ino || moved.mode !== plan.beforeMode || moved.nlink !== plan.beforeIdentity.nlink || moved.text !== plan.before) {
|
|
112
|
+
throw new FileChangedError(plan.path);
|
|
113
|
+
}
|
|
114
|
+
const quarantine = join(dirname(plan.abs), `.hara-delete-${process.pid}-${randomUUID()}.tmp`);
|
|
115
|
+
verifyAtomicWriteBoundary(plan.writeBoundary);
|
|
116
|
+
renameSync(staging, quarantine);
|
|
117
|
+
plan.quarantine = quarantine;
|
|
118
|
+
}
|
|
119
|
+
/** Move a delete quarantine to a fresh name and verify that exact moved path before restore/cleanup. If a
|
|
120
|
+
* concurrent process replaced the known quarantine name, preserve the unexpected inode and fail closed. */
|
|
121
|
+
async function claimDeleteQuarantine(plan, purpose) {
|
|
122
|
+
if (!plan.quarantine || !plan.beforePathIdentity || !plan.beforeIdentity || plan.beforeMode === undefined || !plan.writeBoundary) {
|
|
123
|
+
throw new Error(`missing delete quarantine identity for ${plan.path}`);
|
|
124
|
+
}
|
|
125
|
+
const claimed = join(dirname(plan.quarantine), `.hara-${purpose}-${process.pid}-${randomUUID()}.tmp`);
|
|
126
|
+
verifyAtomicWriteBoundary(plan.writeBoundary);
|
|
127
|
+
renameSync(plan.quarantine, claimed);
|
|
128
|
+
try {
|
|
129
|
+
const pathInfo = await lstat(claimed);
|
|
130
|
+
const expectedPath = plan.beforePathIdentity;
|
|
131
|
+
const expectedLink = plan.beforeLinkTarget !== undefined;
|
|
132
|
+
if (pathInfo.dev !== expectedPath.dev ||
|
|
133
|
+
pathInfo.ino !== expectedPath.ino ||
|
|
134
|
+
(pathInfo.mode & 0o777) !== expectedPath.mode ||
|
|
135
|
+
pathInfo.nlink !== expectedPath.nlink ||
|
|
136
|
+
pathInfo.isSymbolicLink() !== expectedLink ||
|
|
137
|
+
(expectedLink && await readlink(claimed) !== plan.beforeLinkTarget)) {
|
|
138
|
+
throw new FileChangedError(plan.path);
|
|
139
|
+
}
|
|
140
|
+
if (!expectedLink) {
|
|
141
|
+
const file = await readRegularFileSnapshotNoFollow(claimed);
|
|
142
|
+
if (file.dev !== plan.beforeIdentity.dev ||
|
|
143
|
+
file.ino !== plan.beforeIdentity.ino ||
|
|
144
|
+
file.mode !== plan.beforeMode ||
|
|
145
|
+
file.nlink !== plan.beforeIdentity.nlink ||
|
|
146
|
+
file.text !== plan.before) {
|
|
147
|
+
throw new FileChangedError(plan.path);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return claimed;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
try {
|
|
154
|
+
await restoreMovedFile(claimed, plan.quarantine);
|
|
155
|
+
}
|
|
156
|
+
catch (restoreError) {
|
|
157
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; unexpected quarantine is preserved at ${claimed}: ${restoreError?.message ?? String(restoreError)}`);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
11
162
|
registerTool({
|
|
12
163
|
name: "apply_patch",
|
|
13
164
|
description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
|
|
@@ -60,6 +211,9 @@ registerTool({
|
|
|
60
211
|
if (typeof ch.path !== "string" || !ch.path)
|
|
61
212
|
return `Error: ${tag} is missing a path. Nothing written.`;
|
|
62
213
|
const p = abs(ch.path);
|
|
214
|
+
const denied = sensitiveFileError(p, "patch");
|
|
215
|
+
if (denied)
|
|
216
|
+
return `Error: ${tag}: ${denied} Nothing written.`;
|
|
63
217
|
if (plannedPaths.has(p))
|
|
64
218
|
return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
|
|
65
219
|
plannedPaths.add(p);
|
|
@@ -78,44 +232,70 @@ registerTool({
|
|
|
78
232
|
}
|
|
79
233
|
if (type === "delete") {
|
|
80
234
|
let before;
|
|
235
|
+
let pathInfo;
|
|
236
|
+
let beforeLinkTarget;
|
|
237
|
+
let writeBoundary;
|
|
81
238
|
try {
|
|
82
|
-
|
|
239
|
+
writeBoundary = bindAtomicParentEntryPath(p, "patch");
|
|
240
|
+
pathInfo = await lstat(writeBoundary.target);
|
|
241
|
+
if (pathInfo.isSymbolicLink())
|
|
242
|
+
beforeLinkTarget = await readlink(writeBoundary.target);
|
|
243
|
+
const target = resolveVerifiedModelPath(writeBoundary.target, "patch");
|
|
244
|
+
before = await readVerifiedRegularFileSnapshot(target, undefined, "patch");
|
|
83
245
|
}
|
|
84
|
-
catch {
|
|
85
|
-
return `Error: ${tag} delete ${ch.path}: file not found. Nothing written.`;
|
|
246
|
+
catch (error) {
|
|
247
|
+
return `Error: ${tag} delete ${ch.path}: ${error?.message ?? "file not found"}. Nothing written.`;
|
|
86
248
|
}
|
|
87
|
-
plans.push({
|
|
249
|
+
plans.push({
|
|
250
|
+
path: ch.path,
|
|
251
|
+
abs: writeBoundary.target,
|
|
252
|
+
type,
|
|
253
|
+
before: before.text,
|
|
254
|
+
beforeMode: before.mode,
|
|
255
|
+
beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink },
|
|
256
|
+
beforePathIdentity: { dev: pathInfo.dev, ino: pathInfo.ino, mode: pathInfo.mode & 0o777, nlink: pathInfo.nlink },
|
|
257
|
+
beforeLinkTarget,
|
|
258
|
+
writeBoundary,
|
|
259
|
+
after: null,
|
|
260
|
+
existed: true,
|
|
261
|
+
});
|
|
88
262
|
}
|
|
89
263
|
else if (type === "create") {
|
|
90
264
|
if (typeof ch.content !== "string")
|
|
91
265
|
return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
|
|
266
|
+
let writeBoundary;
|
|
92
267
|
try {
|
|
93
|
-
|
|
268
|
+
writeBoundary = bindAtomicWritePath(p, "patch");
|
|
269
|
+
await lstat(writeBoundary.target);
|
|
94
270
|
return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
|
|
95
271
|
}
|
|
96
272
|
catch (error) {
|
|
97
|
-
if (error?.code !== "ENOENT")
|
|
273
|
+
if (error?.code !== "ENOENT" || !writeBoundary)
|
|
98
274
|
return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
99
275
|
}
|
|
100
|
-
|
|
276
|
+
if (!writeBoundary)
|
|
277
|
+
return `Error: ${tag} create ${ch.path}: cannot bind a stable parent. Nothing written.`;
|
|
278
|
+
plans.push({ path: ch.path, abs: writeBoundary.target, type, before: "", after: ch.content, existed: false, writeBoundary });
|
|
101
279
|
}
|
|
102
280
|
else {
|
|
103
281
|
// update
|
|
104
282
|
let before;
|
|
283
|
+
let writeBoundary;
|
|
105
284
|
try {
|
|
106
|
-
|
|
285
|
+
writeBoundary = bindAtomicWritePath(p, "patch");
|
|
286
|
+
before = await readVerifiedRegularFileSnapshot(writeBoundary.target, undefined, "patch");
|
|
107
287
|
}
|
|
108
|
-
catch {
|
|
109
|
-
return `Error: ${tag} update ${ch.path}: cannot read (use type:create for a new file). Nothing written.`;
|
|
288
|
+
catch (error) {
|
|
289
|
+
return `Error: ${tag} update ${ch.path}: cannot read (${error?.message ?? "unknown error"}; use type:create for a new file). Nothing written.`;
|
|
110
290
|
}
|
|
111
291
|
if (typeof ch.content === "string" && !ch.edits) {
|
|
112
|
-
plans.push({ path: ch.path, abs:
|
|
292
|
+
plans.push({ path: ch.path, abs: writeBoundary.target, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: ch.content, existed: true, writeBoundary });
|
|
113
293
|
}
|
|
114
294
|
else {
|
|
115
|
-
const res = applyEdits(before, ch.edits ?? []);
|
|
295
|
+
const res = applyEdits(before.text, ch.edits ?? []);
|
|
116
296
|
if ("error" in res)
|
|
117
297
|
return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
|
|
118
|
-
plans.push({ path: ch.path, abs:
|
|
298
|
+
plans.push({ path: ch.path, abs: writeBoundary.target, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: res.text, existed: true, writeBoundary });
|
|
119
299
|
}
|
|
120
300
|
}
|
|
121
301
|
}
|
|
@@ -125,31 +305,41 @@ registerTool({
|
|
|
125
305
|
try {
|
|
126
306
|
for (const pl of plans) {
|
|
127
307
|
if (pl.type === "delete") {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
throw new FileChangedError(pl.path);
|
|
134
|
-
}
|
|
135
|
-
if (current !== pl.before)
|
|
136
|
-
throw new FileChangedError(pl.path);
|
|
137
|
-
await unlink(pl.abs);
|
|
308
|
+
// Atomic move-then-inspect: a replacement inode is moved aside and restored, never unlinked.
|
|
309
|
+
// The expected inode remains quarantined until every other patch step has committed.
|
|
310
|
+
await quarantineExpectedDelete(pl);
|
|
138
311
|
}
|
|
139
312
|
else {
|
|
140
|
-
await atomicWriteText(pl.abs, pl.after, {
|
|
313
|
+
pl.committed = await atomicWriteText(pl.abs, pl.after, {
|
|
314
|
+
expected: pl.existed ? pl.before : null,
|
|
315
|
+
expectedIdentity: pl.existed ? pl.beforeIdentity : undefined,
|
|
316
|
+
boundary: pl.writeBoundary,
|
|
317
|
+
});
|
|
141
318
|
}
|
|
142
319
|
applied.push(pl);
|
|
143
320
|
}
|
|
144
321
|
}
|
|
145
322
|
catch (e) {
|
|
146
323
|
const rollbackFailures = [];
|
|
147
|
-
|
|
324
|
+
// A delete becomes a visible mutation at its first rename, before its verification can finish. Include
|
|
325
|
+
// that partially staged plan in rollback instead of claiming that an empty `applied` list means the
|
|
326
|
+
// tree was untouched.
|
|
327
|
+
const rollbackPlans = [...applied];
|
|
328
|
+
for (const pl of plans) {
|
|
329
|
+
if (pl.quarantine && !rollbackPlans.includes(pl))
|
|
330
|
+
rollbackPlans.push(pl);
|
|
331
|
+
}
|
|
332
|
+
for (const pl of rollbackPlans.reverse()) {
|
|
148
333
|
try {
|
|
149
|
-
if (pl.type === "
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await
|
|
334
|
+
if (pl.type === "delete") {
|
|
335
|
+
if (!pl.quarantine)
|
|
336
|
+
throw new Error(`missing delete quarantine for ${pl.path}`);
|
|
337
|
+
const claimed = await claimDeleteQuarantine(pl, "restore");
|
|
338
|
+
await restoreMovedFile(claimed, pl.abs);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
await rollbackWrittenPlan(pl);
|
|
342
|
+
}
|
|
153
343
|
}
|
|
154
344
|
catch (rollbackError) {
|
|
155
345
|
rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
@@ -160,13 +350,42 @@ registerTool({
|
|
|
160
350
|
}
|
|
161
351
|
return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
|
|
162
352
|
}
|
|
353
|
+
// Deletes become final only after every commit step succeeds. Cleanup cannot affect visible paths;
|
|
354
|
+
// report a rare failure rather than rolling back after another quarantine may already be removed.
|
|
355
|
+
const cleanupFailures = [];
|
|
356
|
+
for (const pl of plans) {
|
|
357
|
+
for (const warning of pl.committed?.warnings ?? [])
|
|
358
|
+
cleanupFailures.push(`${pl.path}: ${warning}`);
|
|
359
|
+
}
|
|
360
|
+
for (const pl of plans) {
|
|
361
|
+
if (!pl.quarantine)
|
|
362
|
+
continue;
|
|
363
|
+
try {
|
|
364
|
+
const claimed = await claimDeleteQuarantine(pl, "cleanup");
|
|
365
|
+
if (!pl.beforePathIdentity)
|
|
366
|
+
throw new Error(`missing delete path identity for ${pl.path}`);
|
|
367
|
+
discardClaimedPath(claimed, { ...pl.beforePathIdentity, linkTarget: pl.beforeLinkTarget });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
cleanupFailures.push(`${pl.path}: old entry cleanup was refused (${error?.message ?? String(error)})`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
163
373
|
// All writes succeeded → now show diffs + record the undo snapshot.
|
|
164
374
|
const summary = plans.map((pl) => {
|
|
165
375
|
emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
|
|
166
376
|
return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
|
|
167
377
|
});
|
|
168
|
-
recordEdit(plans.map((pl) => ({
|
|
378
|
+
recordEdit(plans.map((pl) => ({
|
|
379
|
+
path: pl.path,
|
|
380
|
+
absPath: pl.abs,
|
|
381
|
+
before: pl.existed ? pl.before : null,
|
|
382
|
+
beforeMode: pl.beforeMode,
|
|
383
|
+
linkTarget: pl.type === "delete" ? pl.beforeLinkTarget : undefined,
|
|
384
|
+
removed: pl.type === "delete",
|
|
385
|
+
committed: pl.committed,
|
|
386
|
+
after: pl.after ?? undefined,
|
|
387
|
+
})));
|
|
169
388
|
invalidateFileCandidates(ctx.cwd);
|
|
170
|
-
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}
|
|
389
|
+
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.` + (cleanupFailures.length ? ` Warning: ${cleanupFailures.join("; ")}` : "");
|
|
171
390
|
},
|
|
172
391
|
});
|