@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/builtin.js
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { resolve, isAbsolute } from "node:path";
|
|
5
4
|
import { stdout as procOut } from "node:process";
|
|
6
5
|
import { registerTool } from "./registry.js";
|
|
7
6
|
import { runShell } from "../sandbox.js";
|
|
8
|
-
import {
|
|
7
|
+
import { nearestPaths } from "../fs-walk.js";
|
|
9
8
|
import { emitDiff } from "../diff.js";
|
|
10
9
|
import { recordEdit } from "../undo.js";
|
|
11
|
-
import { atomicWriteText } from "../fs-write.js";
|
|
10
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
12
11
|
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
13
|
-
import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
12
|
+
import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, streamFileSlice } from "../fs-read.js";
|
|
14
13
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
14
|
+
import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
15
|
+
import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
15
16
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
16
17
|
const MAX = 100_000;
|
|
17
|
-
/** Package installs are network-bound and routinely exceed the foreground cap.
|
|
18
|
-
* explicit foreground/background choice, detach these commands into the existing job system so the UI
|
|
19
|
-
* remains responsive and the agent can poll output instead of waiting five minutes for a hard kill. */
|
|
18
|
+
/** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
|
|
20
19
|
export function isPackageInstallCommand(command) {
|
|
21
20
|
return /(?:^|[;&|]\s*)(?:npm\s+(?:i|install|ci)\b|pnpm\s+(?:i|install|add)\b|yarn(?:\s+(?:install|add))?(?:\s|$)|bun\s+(?:i|install|add)\b)/i.test(command.trim());
|
|
22
21
|
}
|
|
22
|
+
/** Resolve a bounded foreground timeout. Installs get a longer default, but remain attached so a headless
|
|
23
|
+
* run cannot exit, kill its background child, and leave node_modules half-written. */
|
|
24
|
+
export function shellTimeoutMs(command, requested) {
|
|
25
|
+
if (Number.isFinite(requested) && requested > 0) {
|
|
26
|
+
return Math.min(Math.max(Math.trunc(requested), 1_000), 3_600_000);
|
|
27
|
+
}
|
|
28
|
+
return isPackageInstallCommand(command) ? 900_000 : 300_000;
|
|
29
|
+
}
|
|
23
30
|
export function isNgrokTunnelCommand(command) {
|
|
24
31
|
return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
|
|
25
32
|
}
|
|
@@ -79,7 +86,6 @@ export function capHeadTail(s, max = MAX) {
|
|
|
79
86
|
}
|
|
80
87
|
const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
|
|
81
88
|
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
82
|
-
const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
|
|
83
89
|
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
84
90
|
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
85
91
|
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
@@ -119,20 +125,23 @@ registerTool({
|
|
|
119
125
|
kind: "read",
|
|
120
126
|
async run(input, ctx) {
|
|
121
127
|
const p = abs(input.path, ctx.cwd);
|
|
128
|
+
const denied = sensitiveFileError(p, "read");
|
|
129
|
+
if (denied)
|
|
130
|
+
return denied;
|
|
122
131
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
const target = resolveVerifiedModelPath(p, "read");
|
|
133
|
+
// streamFileSlice opens O_NONBLOCK, validates the same fd as a regular file, and stops after the
|
|
134
|
+
// requested window. Using it for every size removes the path-level stat→read race entirely.
|
|
135
|
+
return cap(await streamFileSlice(target, input.offset, input.limit ?? READ_LINES, {
|
|
136
|
+
lineCap: LINE_CAP,
|
|
137
|
+
protectSensitive: true,
|
|
138
|
+
}));
|
|
130
139
|
}
|
|
131
140
|
catch (e) {
|
|
132
141
|
if (e instanceof BinaryFileError)
|
|
133
142
|
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
134
143
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
135
|
-
return `Error: cannot read ${input.path}: ${e.
|
|
144
|
+
return `Error: cannot read ${input.path}: ${e.message ?? e.code}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
136
145
|
}
|
|
137
146
|
},
|
|
138
147
|
});
|
|
@@ -150,28 +159,41 @@ registerTool({
|
|
|
150
159
|
kind: "edit",
|
|
151
160
|
async run(input, ctx) {
|
|
152
161
|
const p = abs(input.path, ctx.cwd);
|
|
162
|
+
const denied = sensitiveFileError(p, "write");
|
|
163
|
+
if (denied)
|
|
164
|
+
return denied;
|
|
153
165
|
if (typeof input.content !== "string")
|
|
154
166
|
return "Error: write_file `content` must be a string. No changes written.";
|
|
155
|
-
let
|
|
167
|
+
let prevSnapshot = null;
|
|
168
|
+
let boundary;
|
|
156
169
|
try {
|
|
157
|
-
|
|
170
|
+
boundary = bindAtomicWritePath(p, "write");
|
|
171
|
+
prevSnapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "write");
|
|
158
172
|
}
|
|
159
173
|
catch (error) {
|
|
160
|
-
if (error?.code !== "ENOENT")
|
|
161
|
-
return `Error: cannot inspect ${input.path}: ${error?.
|
|
174
|
+
if (error?.code !== "ENOENT" || !boundary)
|
|
175
|
+
return `Error: cannot inspect ${input.path}: ${error?.message ?? error?.code}. No changes written.`;
|
|
162
176
|
}
|
|
177
|
+
if (!boundary)
|
|
178
|
+
return `Error: cannot bind ${input.path} to a stable parent. No changes written.`;
|
|
179
|
+
const prev = prevSnapshot?.text ?? null;
|
|
163
180
|
if (prev === input.content)
|
|
164
181
|
return `Unchanged ${p} (${input.content.length} chars already match).`;
|
|
182
|
+
let committed;
|
|
165
183
|
try {
|
|
166
|
-
await atomicWriteText(
|
|
184
|
+
committed = await atomicWriteText(boundary.target, input.content, {
|
|
185
|
+
expected: prev,
|
|
186
|
+
expectedIdentity: prevSnapshot ?? undefined,
|
|
187
|
+
boundary,
|
|
188
|
+
});
|
|
167
189
|
}
|
|
168
190
|
catch (error) {
|
|
169
191
|
return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
170
192
|
}
|
|
171
193
|
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
172
|
-
recordEdit([{ path: input.path, absPath:
|
|
194
|
+
recordEdit([{ path: input.path, absPath: boundary.target, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
|
|
173
195
|
invalidateFileCandidates(ctx.cwd);
|
|
174
|
-
return `Wrote ${String(input.content).length} chars to ${p}
|
|
196
|
+
return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
|
|
175
197
|
},
|
|
176
198
|
});
|
|
177
199
|
registerTool({
|
|
@@ -181,21 +203,26 @@ registerTool({
|
|
|
181
203
|
type: "object",
|
|
182
204
|
properties: {
|
|
183
205
|
command: { type: "string" },
|
|
184
|
-
timeout_ms: { type: "number", description: "default 300000 (5 min)
|
|
185
|
-
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task)
|
|
206
|
+
timeout_ms: { type: "number", description: "default 300000 (5 min), or 900000 (15 min) for package installs; bounded to 1s..1h" },
|
|
207
|
+
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); package installs stay foreground unless explicitly requested" },
|
|
186
208
|
},
|
|
187
209
|
required: ["command"],
|
|
188
210
|
},
|
|
189
211
|
kind: "exec",
|
|
190
212
|
async run(input, ctx) {
|
|
213
|
+
const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
|
|
214
|
+
if (protectedReason) {
|
|
215
|
+
return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
|
|
216
|
+
"This deny is not bypassed by full-auto. Restart hara with HARA_ALLOW_SENSITIVE_FILES=1 only for an intentional, user-approved exposure.");
|
|
217
|
+
}
|
|
191
218
|
if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
|
|
192
219
|
return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
|
|
193
220
|
"Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
|
|
194
221
|
}
|
|
195
|
-
|
|
196
|
-
if (input.background || autoPackageJob) {
|
|
222
|
+
if (input.background) {
|
|
197
223
|
const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
|
|
198
|
-
|
|
224
|
+
const safeCommand = redactToolSubprocessOutput(String(input.command));
|
|
225
|
+
return `Started background job ${id}: \`${safeCommand}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on it.`;
|
|
199
226
|
}
|
|
200
227
|
// Network fault tolerance — short-circuit if this command targets a host already found unreachable this
|
|
201
228
|
// session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
|
|
@@ -213,37 +240,38 @@ registerTool({
|
|
|
213
240
|
return `Skipped without running: host "${blocked}" already failed to connect earlier in THIS session — hara does not retry network operations to a host known unreachable this session (a retry just hangs ~75s again). Do not swap in a public mirror (won't serve private repos) or switch protocols; diagnose instead.${proxyHint(blocked)}`;
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
? (s) => {
|
|
219
|
-
buf += s;
|
|
220
|
-
let i;
|
|
221
|
-
while ((i = buf.indexOf("\n")) >= 0) {
|
|
222
|
-
ctx.ui.notice(buf.slice(0, i));
|
|
223
|
-
buf = buf.slice(i + 1);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
243
|
+
const liveEmit = ctx.ui
|
|
244
|
+
? (line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))
|
|
226
245
|
: procOut.isTTY
|
|
227
|
-
? (
|
|
228
|
-
:
|
|
246
|
+
? (line) => procOut.write(line)
|
|
247
|
+
: null;
|
|
248
|
+
// stdout/stderr are independent byte streams. A shared partial-line buffer could splice stderr into
|
|
249
|
+
// the middle of a stdout credential and defeat exact-value redaction in the live UI.
|
|
250
|
+
const liveStdout = liveEmit ? createToolOutputLineRedactor(liveEmit) : null;
|
|
251
|
+
const liveStderr = liveEmit ? createToolOutputLineRedactor(liveEmit) : null;
|
|
252
|
+
const live = liveEmit
|
|
253
|
+
? (s, stream) => (stream === "stdout" ? liveStdout : liveStderr).push(s)
|
|
254
|
+
: undefined;
|
|
255
|
+
const flushLive = () => { liveStdout?.flush(); liveStderr?.flush(); };
|
|
256
|
+
const timeout = shellTimeoutMs(input.command, input.timeout_ms);
|
|
229
257
|
try {
|
|
230
258
|
const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
|
|
231
|
-
timeout
|
|
259
|
+
timeout,
|
|
232
260
|
maxBuffer: 10 * 1024 * 1024,
|
|
233
261
|
onData: live,
|
|
234
262
|
});
|
|
235
|
-
|
|
236
|
-
ctx.ui.notice(buf); // flush trailing partial line
|
|
263
|
+
flushLive();
|
|
237
264
|
const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
|
|
238
|
-
return capHeadTail(combined.trim() || "(no output)");
|
|
265
|
+
return capHeadTail(redactToolSubprocessOutput(combined.trim() || "(no output)"));
|
|
239
266
|
}
|
|
240
267
|
catch (e) {
|
|
268
|
+
flushLive();
|
|
241
269
|
let base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
|
|
242
270
|
// Timeout gets an ACTIONABLE next step, not just a corpse — the model (and user) should pick a
|
|
243
271
|
// lane instead of blind-retrying into the same wall.
|
|
244
272
|
if (/timed out after \d+ms/.test(String(e.message))) {
|
|
245
273
|
base +=
|
|
246
|
-
`\n⏱ hara: the command hit its ${
|
|
274
|
+
`\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
|
|
247
275
|
`a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
|
|
248
276
|
`a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
|
|
249
277
|
}
|
|
@@ -256,10 +284,10 @@ registerTool({
|
|
|
256
284
|
if (host) {
|
|
257
285
|
markHostUnreachable(host);
|
|
258
286
|
ctx.ui?.notice(`↯ ${host} marked unreachable for this session — won't retry network ops to it`);
|
|
259
|
-
return capHeadTail(base + proxyHint(host));
|
|
287
|
+
return capHeadTail(redactToolSubprocessOutput(base + proxyHint(host)));
|
|
260
288
|
}
|
|
261
289
|
}
|
|
262
|
-
return capHeadTail(base);
|
|
290
|
+
return capHeadTail(redactToolSubprocessOutput(base));
|
|
263
291
|
}
|
|
264
292
|
},
|
|
265
293
|
});
|
|
@@ -282,14 +310,14 @@ registerTool({
|
|
|
282
310
|
const js = listJobs();
|
|
283
311
|
if (!js.length)
|
|
284
312
|
return "(no background jobs)";
|
|
285
|
-
return js.map((j) => `${j.id} [${j.status}${j.code != null ? " " + j.code : ""}] ${Math.round(j.ageMs / 1000)}s ${j.command}`).join("\n");
|
|
313
|
+
return js.map((j) => `${j.id} [${j.status}${j.code != null ? " " + j.code : ""}] ${Math.round(j.ageMs / 1000)}s ${redactToolSubprocessOutput(j.command)}`).join("\n");
|
|
286
314
|
}
|
|
287
315
|
const id = String(input.id ?? "");
|
|
288
316
|
if (!id)
|
|
289
317
|
return "Error: `id` is required for tail/kill.";
|
|
290
318
|
if (action === "tail") {
|
|
291
319
|
const t = tailJob(id, Number(input.lines) || 40);
|
|
292
|
-
return t == null ? `No job ${id}.` : t.trim() || "(no output yet)";
|
|
320
|
+
return t == null ? `No job ${id}.` : redactToolSubprocessOutput(t.trim() || "(no output yet)");
|
|
293
321
|
}
|
|
294
322
|
if (action === "kill")
|
|
295
323
|
return killJob(id) ? `Killed ${id}.` : `No running job ${id} (already exited/killed or unknown).`;
|
package/dist/tools/codebase.js
CHANGED
|
@@ -72,7 +72,9 @@ registerTool({
|
|
|
72
72
|
// to pure lexical when no index/embedder — zero behaviour change for the default install.
|
|
73
73
|
const out = [];
|
|
74
74
|
const seen = new Set();
|
|
75
|
-
|
|
75
|
+
// Tool execution can be dispatched to a registered agent home without changing process.cwd(). Its
|
|
76
|
+
// semantic-search provider/index settings belong to the tool context, not to the launcher directory.
|
|
77
|
+
const cfg = loadConfig({ cwd: ctx.cwd });
|
|
76
78
|
const embed = getEmbedder(cfg);
|
|
77
79
|
if (embed && indexExists("repo", ctx.cwd)) {
|
|
78
80
|
try {
|
package/dist/tools/computer.js
CHANGED
|
@@ -284,98 +284,104 @@ export function computerBackends() {
|
|
|
284
284
|
return "PowerShell (built-in)";
|
|
285
285
|
return `unsupported (${process.platform})`;
|
|
286
286
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
"
|
|
294
|
-
"
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
287
|
+
// Gateway runs get NO computer tool by default (HARA_GATEWAY_COMPUTER=1 opts back in): a chat-driven, full-auto
|
|
288
|
+
// agent reaching for desktop automation is how "check Feishu" turns into blindly clicking another app's windows.
|
|
289
|
+
// In a gateway context the right lever is an API/skill, and send_file already covers delivery — so the safe
|
|
290
|
+
// default is to not offer screen control at all rather than trust the model to decline it.
|
|
291
|
+
if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
|
|
292
|
+
registerTool({
|
|
293
|
+
name: "computer",
|
|
294
|
+
description: "Control the screen to operate desktop software (not just the browser). ALWAYS `activate` the target app " +
|
|
295
|
+
"FIRST (e.g. activate WeChat) — otherwise screenshots/clicks hit the terminal hara runs in, not the app. " +
|
|
296
|
+
"Then prefer grounding over guessing pixels: pass `target` (e.g. 'the Send button') to click/move and it's " +
|
|
297
|
+
"located by a vision model; or `find` to just get coordinates. Workflow: activate → screenshot → click a " +
|
|
298
|
+
"target → re-screenshot to verify. When typing, type the ACTUAL text — never placeholders. Opt-in and " +
|
|
299
|
+
"permission-gated (tier + per-app allowlist).",
|
|
300
|
+
input_schema: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
action: { type: "string", enum: ["screenshot", "activate", "find", "click", "move", "type", "key"] },
|
|
304
|
+
app: { type: "string", description: "app to bring to the foreground (activate) — e.g. 'WeChat'. Do this BEFORE screenshot/click so they hit the app, not the terminal." },
|
|
305
|
+
target: { type: "string", description: "describe a UI element to locate (find) or click/move to — e.g. 'the Send button'. Preferred over x,y." },
|
|
306
|
+
x: { type: "number", description: "x pixel (click/move; or use `target`)" },
|
|
307
|
+
y: { type: "number", description: "y pixel (click/move; or use `target`)" },
|
|
308
|
+
text: { type: "string", description: "text to type (type)" },
|
|
309
|
+
keys: { type: "string", description: "key or combo, e.g. 'return', 'cmd+c' (key)" },
|
|
310
|
+
focus: { type: "string", description: "screenshot only: what to look for — focuses the read" },
|
|
311
|
+
},
|
|
312
|
+
required: ["action"],
|
|
306
313
|
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
/* fall through to path */
|
|
314
|
+
kind: "computer",
|
|
315
|
+
async run(input, ctx) {
|
|
316
|
+
const cfg = loadConfig();
|
|
317
|
+
const tier = cfg.computerUse;
|
|
318
|
+
if (tier === "off")
|
|
319
|
+
return "Screen control is off. Enable it: `hara config set computerUse read|click|full` (and `hara config set computerApps \"App Name, …\"` for the click/type allowlist).";
|
|
320
|
+
const action = String(input.action ?? "");
|
|
321
|
+
if (!actionAllowed(tier, action))
|
|
322
|
+
return `'${action}' needs a higher tier (current computerUse=${tier}). Raise it with \`hara config set computerUse …\`.`;
|
|
323
|
+
// Bring the target app to the foreground first — without this, clicks land on the terminal hara runs in.
|
|
324
|
+
if (action === "activate") {
|
|
325
|
+
const app = String(input.app ?? input.target ?? "");
|
|
326
|
+
if (!app)
|
|
327
|
+
return "activate needs an `app` name (e.g. 'WeChat').";
|
|
328
|
+
if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
|
|
329
|
+
return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
|
|
330
|
+
const r = activateApp(app);
|
|
331
|
+
return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
|
|
332
|
+
}
|
|
333
|
+
if (action !== "screenshot" && action !== "find") {
|
|
334
|
+
// per-app allowlist: only act when an allowlisted app is frontmost (the key guard against wrong-window clicks)
|
|
335
|
+
if (!cfg.computerApps.length)
|
|
336
|
+
return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
|
|
337
|
+
const app = frontmostApp();
|
|
338
|
+
const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
|
|
339
|
+
if (!allowed)
|
|
340
|
+
return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
|
|
341
|
+
}
|
|
342
|
+
if (action === "screenshot") {
|
|
343
|
+
const s = screenshot();
|
|
344
|
+
if (s.error)
|
|
345
|
+
return fail(`screenshot — ${s.error}`);
|
|
346
|
+
if (ctx.describeImage) {
|
|
347
|
+
try {
|
|
348
|
+
const desc = await ctx.describeImage(s.path, input.focus ? String(input.focus) : undefined);
|
|
349
|
+
if (desc)
|
|
350
|
+
return ok(`Screenshot (read via vision):\n${desc}`);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
/* fall through to path */
|
|
354
|
+
}
|
|
349
355
|
}
|
|
356
|
+
return ok(`Screenshot saved to ${s.path}. Configure a vision model so I can read it: \`hara config set visionModel <model>\`.`);
|
|
350
357
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
358
|
+
// Grounding: locate a described element and turn it into screen coordinates (more reliable than guessing
|
|
359
|
+
// pixels from a text description). Used for `find`, and for click/move when given a `target` and no x,y.
|
|
360
|
+
const needsLocate = action === "find" || ((action === "click" || action === "move") && input.target != null && (input.x == null || input.y == null));
|
|
361
|
+
if (needsLocate) {
|
|
362
|
+
const target = String(input.target ?? "");
|
|
363
|
+
if (!target)
|
|
364
|
+
return action === "find" ? "find needs a `target` (what to locate)." : "click/move needs `x,y` or a `target`.";
|
|
365
|
+
if (!ctx.locate)
|
|
366
|
+
return "Grounding needs a vision model that can see images — set one: `hara config set visionModel <model>`.";
|
|
367
|
+
const s = screenshot();
|
|
368
|
+
if (s.error)
|
|
369
|
+
return fail(`screenshot — ${s.error}`);
|
|
370
|
+
const loc = await ctx.locate(s.path, target);
|
|
371
|
+
if (!loc)
|
|
372
|
+
return fail(`couldn't locate "${target}" on screen — try a screenshot first, or rephrase the target`);
|
|
373
|
+
const size = screenSize();
|
|
374
|
+
if (!size)
|
|
375
|
+
return fail(`located "${target}" but couldn't read the screen size to convert coordinates`);
|
|
376
|
+
const gx = Math.round(loc.x * size.w);
|
|
377
|
+
const gy = Math.round(loc.y * size.h);
|
|
378
|
+
if (action === "find")
|
|
379
|
+
return ok(`"${target}" is at ~${gx},${gy} (${Math.round(loc.x * 100)}% across, ${Math.round(loc.y * 100)}% down).`);
|
|
380
|
+
input.x = gx;
|
|
381
|
+
input.y = gy;
|
|
382
|
+
}
|
|
383
|
+
const r = pointerOrKeyboard(action, input);
|
|
384
|
+
return r.ok ? ok(`✓ ${r.msg}${needsLocate ? ` (located "${input.target}")` : ""}`) : fail(r.msg);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
package/dist/tools/cron.js
CHANGED
|
@@ -10,6 +10,7 @@ import { parseSchedule, describeSchedule, nextRun, validTz } from "../cron/sched
|
|
|
10
10
|
import { runJobOnce } from "../cron/runner.js";
|
|
11
11
|
import { parseDeliver } from "../cron/deliver.js";
|
|
12
12
|
import { isInstalled } from "../cron/install.js";
|
|
13
|
+
import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
13
14
|
const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
|
|
14
15
|
registerTool({
|
|
15
16
|
name: "cronjob",
|
|
@@ -70,6 +71,11 @@ registerTool({
|
|
|
70
71
|
return `Error: ${d.error}`;
|
|
71
72
|
}
|
|
72
73
|
const mode = input.mode === "org" || input.mode === "command" ? input.mode : "print";
|
|
74
|
+
if (mode === "command") {
|
|
75
|
+
const denied = sensitiveShellCommandReason(task, ctx.cwd);
|
|
76
|
+
if (denied)
|
|
77
|
+
return `Error: scheduled shell command crosses Hara's protected secret boundary (${denied}).`;
|
|
78
|
+
}
|
|
73
79
|
const job = addJob({
|
|
74
80
|
name: input.name ? String(input.name) : task.slice(0, 48),
|
|
75
81
|
schedule: sched,
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import { isAbsolute, resolve } from "node:path";
|
|
3
2
|
import { registerTool } from "./registry.js";
|
|
4
3
|
import { nearestPaths } from "../fs-walk.js";
|
|
5
4
|
import { emitDiff } from "../diff.js";
|
|
6
5
|
import { applyEdits } from "./apply-core.js";
|
|
7
6
|
import { recordEdit } from "../undo.js";
|
|
8
|
-
import { atomicWriteText } from "../fs-write.js";
|
|
7
|
+
import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
|
|
9
8
|
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
9
|
+
import { readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
10
|
+
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
10
11
|
registerTool({
|
|
11
12
|
name: "edit_file",
|
|
12
13
|
description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
|
|
@@ -41,31 +42,43 @@ registerTool({
|
|
|
41
42
|
kind: "edit",
|
|
42
43
|
async run(input, ctx) {
|
|
43
44
|
const p = isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path);
|
|
45
|
+
const denied = sensitiveFileError(p, "edit");
|
|
46
|
+
if (denied)
|
|
47
|
+
return denied;
|
|
44
48
|
const edits = Array.isArray(input.edits) && input.edits.length
|
|
45
49
|
? input.edits
|
|
46
50
|
: [{ old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }];
|
|
47
|
-
let
|
|
51
|
+
let snapshot;
|
|
52
|
+
let boundary;
|
|
48
53
|
try {
|
|
49
|
-
|
|
54
|
+
boundary = bindAtomicWritePath(p, "edit");
|
|
55
|
+
snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "edit");
|
|
50
56
|
}
|
|
51
|
-
catch {
|
|
57
|
+
catch (error) {
|
|
52
58
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
53
|
-
return `Error: cannot read ${input.path} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
59
|
+
return `Error: cannot read ${input.path}: ${error?.message ?? "unknown error"} (use write_file to create a new file).` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
54
60
|
}
|
|
61
|
+
const text = snapshot.text;
|
|
55
62
|
const res = applyEdits(text, edits);
|
|
56
63
|
if ("error" in res)
|
|
57
64
|
return `Error: ${res.error} in ${input.path}. No changes written.`;
|
|
65
|
+
let committed;
|
|
58
66
|
try {
|
|
59
|
-
await atomicWriteText(
|
|
67
|
+
committed = await atomicWriteText(boundary.target, res.text, {
|
|
68
|
+
expected: text,
|
|
69
|
+
expectedIdentity: snapshot,
|
|
70
|
+
boundary,
|
|
71
|
+
});
|
|
60
72
|
}
|
|
61
73
|
catch (error) {
|
|
62
74
|
return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
63
75
|
}
|
|
64
76
|
emitDiff(input.path, text, res.text, ctx.ui);
|
|
65
|
-
recordEdit([{ path: input.path, absPath:
|
|
77
|
+
recordEdit([{ path: input.path, absPath: boundary.target, before: text, beforeMode: snapshot.mode, committed, after: res.text }]);
|
|
66
78
|
invalidateFileCandidates(ctx.cwd);
|
|
67
79
|
const note = res.fuzzy ? " (quote-normalized)" : "";
|
|
68
80
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
69
|
-
return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}
|
|
81
|
+
return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.` +
|
|
82
|
+
(committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
|
|
70
83
|
},
|
|
71
84
|
});
|