@nanhara/hara 0.122.0 → 0.122.2
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 +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- 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/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- 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/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/cron/runner.js
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
// scheduler (see install.ts). A lock file prevents overlapping ticks from double-firing a slow job.
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { deliverResult } from "./deliver.js";
|
|
6
|
-
import { existsSync, mkdirSync,
|
|
6
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { platform } from "node:os";
|
|
7
9
|
import { join } from "node:path";
|
|
8
10
|
import { loadJobs, recordRun, recordAlert, findJob, cronDir, logPath } from "./store.js";
|
|
9
11
|
import { isDue } from "./schedule.js";
|
|
12
|
+
import { shellCommand } from "../sandbox.js";
|
|
13
|
+
import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
14
|
+
import { createToolOutputLineRedactor, redactToolSubprocessOutput, terminateSubprocessTree, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
10
15
|
/** Jobs that are enabled AND due at `nowMs` (pure — for the tick and for testing). */
|
|
11
16
|
export function dueJobs(jobs, nowMs) {
|
|
12
17
|
return jobs.filter((j) => j.enabled && isDue(j, nowMs));
|
|
@@ -21,9 +26,166 @@ export function selfArgv() {
|
|
|
21
26
|
return underNode && process.argv[1] ? [exec, process.argv[1]] : [exec];
|
|
22
27
|
}
|
|
23
28
|
const lockPath = () => join(cronDir(), ".tick.lock");
|
|
29
|
+
const takeoverPath = () => join(cronDir(), ".tick.lock.takeover");
|
|
24
30
|
// Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
|
|
25
31
|
// we assume PID reuse and take over. A *dead* owner is taken over within one tick regardless (see below).
|
|
26
32
|
const LOCK_STALE_MS = 6 * 60 * 60_000;
|
|
33
|
+
// A takeover/release guard protects only synchronous filesystem operations and should live for milliseconds.
|
|
34
|
+
// Five minutes tolerates long host pauses while still recovering a guard left by a crash or PID reuse.
|
|
35
|
+
const TAKEOVER_GUARD_STALE_MS = 5 * 60_000;
|
|
36
|
+
const DEFAULT_JOB_TIMEOUT_MS = 30 * 60_000;
|
|
37
|
+
const DEFAULT_RUN_LOG_BYTES = 1_000_000;
|
|
38
|
+
const TERMINATE_GRACE_MS = 2_000;
|
|
39
|
+
const MAX_LOCK_RECORD_BYTES = 512;
|
|
40
|
+
function processIsAlive(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// EPERM means the process exists but is not signalable. Only ESRCH proves that an owner is gone.
|
|
47
|
+
return error?.code !== "ESRCH";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function readTickLockFileSnapshot(path) {
|
|
51
|
+
try {
|
|
52
|
+
const before = lstatSync(path);
|
|
53
|
+
if (!before.isFile())
|
|
54
|
+
return null;
|
|
55
|
+
const raw = before.size <= MAX_LOCK_RECORD_BYTES ? readFileSync(path, "utf8").trim() : null;
|
|
56
|
+
const after = lstatSync(path);
|
|
57
|
+
if (before.dev !== after.dev
|
|
58
|
+
|| before.ino !== after.ino
|
|
59
|
+
|| before.size !== after.size
|
|
60
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
61
|
+
|| before.ctimeMs !== after.ctimeMs)
|
|
62
|
+
return null;
|
|
63
|
+
return { raw, dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function parseTickLockSnapshot(file) {
|
|
70
|
+
if (!file?.raw)
|
|
71
|
+
return null;
|
|
72
|
+
const match = /^(\d+):([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/iu.exec(file.raw);
|
|
73
|
+
const pid = Number(match?.[1]);
|
|
74
|
+
if (!match || !Number.isSafeInteger(pid) || pid <= 0)
|
|
75
|
+
return null;
|
|
76
|
+
return { ...file, raw: file.raw, pid };
|
|
77
|
+
}
|
|
78
|
+
function readTickLockSnapshot(path) {
|
|
79
|
+
return parseTickLockSnapshot(readTickLockFileSnapshot(path));
|
|
80
|
+
}
|
|
81
|
+
function sameTickLockFile(left, right) {
|
|
82
|
+
return !!right
|
|
83
|
+
&& left.raw === right.raw
|
|
84
|
+
&& left.dev === right.dev
|
|
85
|
+
&& left.ino === right.ino
|
|
86
|
+
&& left.size === right.size
|
|
87
|
+
&& left.mtimeMs === right.mtimeMs
|
|
88
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
89
|
+
}
|
|
90
|
+
function sameTickLock(left, right) {
|
|
91
|
+
return sameTickLockFile(left, right) && left.pid === right?.pid;
|
|
92
|
+
}
|
|
93
|
+
function staleTickLock(snapshot, nowMs) {
|
|
94
|
+
return nowMs - snapshot.mtimeMs >= LOCK_STALE_MS || !processIsAlive(snapshot.pid);
|
|
95
|
+
}
|
|
96
|
+
/** Recover only a complete, stable guard whose owner is proven dead or whose tiny synchronous lease is
|
|
97
|
+
* far past its lifetime. Recovery deliberately ends this tick: a later invocation acquires a fresh guard,
|
|
98
|
+
* keeping stale-guard deletion and new-guard creation out of the same contender's race window. */
|
|
99
|
+
function prepareTakeoverGuard(path, nowMs) {
|
|
100
|
+
if (!existsSync(path))
|
|
101
|
+
return "clear";
|
|
102
|
+
const observedFile = readTickLockFileSnapshot(path);
|
|
103
|
+
if (!observedFile)
|
|
104
|
+
return "active"; // unreadable/non-regular/unstable is not evidence that deletion is safe
|
|
105
|
+
const observed = parseTickLockSnapshot(observedFile);
|
|
106
|
+
const staleRecord = (snapshot) => nowMs - snapshot.mtimeMs >= TAKEOVER_GUARD_STALE_MS || !processIsAlive(snapshot.pid);
|
|
107
|
+
const staleMalformed = (snapshot) => nowMs - snapshot.mtimeMs >= TAKEOVER_GUARD_STALE_MS;
|
|
108
|
+
if (observed ? !staleRecord(observed) : !staleMalformed(observedFile))
|
|
109
|
+
return "active";
|
|
110
|
+
const currentFile = readTickLockFileSnapshot(path);
|
|
111
|
+
if (!sameTickLockFile(observedFile, currentFile) || !currentFile)
|
|
112
|
+
return "active";
|
|
113
|
+
const current = parseTickLockSnapshot(currentFile);
|
|
114
|
+
if (observed) {
|
|
115
|
+
if (!sameTickLock(observed, current) || !current || !staleRecord(current))
|
|
116
|
+
return "active";
|
|
117
|
+
}
|
|
118
|
+
else if (current || !staleMalformed(currentFile)) {
|
|
119
|
+
return "active";
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
rmSync(path);
|
|
123
|
+
return "recovered";
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return "active";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function writeExclusive(path, token) {
|
|
130
|
+
let fd;
|
|
131
|
+
let created = false;
|
|
132
|
+
try {
|
|
133
|
+
fd = openSync(path, "wx", 0o600);
|
|
134
|
+
created = true;
|
|
135
|
+
writeFileSync(fd, token, { encoding: "utf8" });
|
|
136
|
+
fsyncSync(fd);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!created && error?.code === "EEXIST")
|
|
141
|
+
return false;
|
|
142
|
+
if (fd !== undefined) {
|
|
143
|
+
try {
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
catch { /* preserve the original write error */ }
|
|
147
|
+
fd = undefined;
|
|
148
|
+
}
|
|
149
|
+
if (created)
|
|
150
|
+
try {
|
|
151
|
+
rmSync(path);
|
|
152
|
+
}
|
|
153
|
+
catch { /* the incomplete record will fail closed */ }
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (fd !== undefined)
|
|
158
|
+
try {
|
|
159
|
+
closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
catch { /* best effort */ }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function removeOwnedLock(path, token) {
|
|
165
|
+
try {
|
|
166
|
+
if (readTickLockSnapshot(path)?.raw === token)
|
|
167
|
+
rmSync(path);
|
|
168
|
+
}
|
|
169
|
+
catch { /* best effort */ }
|
|
170
|
+
}
|
|
171
|
+
/** Release the primary lock under the same guard used for stale takeover. Without this, a >6h owner could
|
|
172
|
+
* read its token, get descheduled while a reaper installs a successor, then unlink that successor by name. */
|
|
173
|
+
function releasePrimaryTickLock(lock, takeover, token) {
|
|
174
|
+
const releaseToken = `${process.pid}:${randomUUID()}`;
|
|
175
|
+
if (!writeExclusive(takeover, releaseToken))
|
|
176
|
+
return; // an active reaper owns the transition
|
|
177
|
+
try {
|
|
178
|
+
const current = readTickLockSnapshot(lock);
|
|
179
|
+
if (current?.raw === token)
|
|
180
|
+
rmSync(lock);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* best effort; leaving an uncertain lock is safer than deleting it */
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
removeOwnedLock(takeover, releaseToken);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
27
189
|
/** Keep a per-job log from growing forever: once over ~1MB, retain only the last ~256KB. */
|
|
28
190
|
function capLog(log) {
|
|
29
191
|
try {
|
|
@@ -37,13 +199,22 @@ function capLog(log) {
|
|
|
37
199
|
}
|
|
38
200
|
/** Run one job's task in a fresh hara process (full-auto, no prompts), appending output to its log.
|
|
39
201
|
* Exported so `hara cron run <id>` can fire a job on demand, ignoring its schedule. */
|
|
40
|
-
export function runJobOnce(job) {
|
|
202
|
+
export function runJobOnce(job, options = {}) {
|
|
41
203
|
return new Promise((resolve) => {
|
|
42
|
-
|
|
204
|
+
const timeoutMs = Math.min(Math.max(100, options.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS), 24 * 60 * 60_000);
|
|
205
|
+
const maxLogBytes = Math.min(Math.max(4_096, options.maxLogBytes ?? DEFAULT_RUN_LOG_BYTES), 16 * 1024 * 1024);
|
|
206
|
+
mkdirSync(join(cronDir(), "logs"), { recursive: true, mode: 0o700 });
|
|
207
|
+
try {
|
|
208
|
+
chmodSync(cronDir(), 0o700);
|
|
209
|
+
chmodSync(join(cronDir(), "logs"), 0o700);
|
|
210
|
+
}
|
|
211
|
+
catch { /* best effort */ }
|
|
43
212
|
const log = logPath(job.id);
|
|
44
213
|
capLog(log);
|
|
45
214
|
try {
|
|
46
|
-
|
|
215
|
+
const safeName = redactToolSubprocessOutput(String(job.name).replace(/[\r\n\0]/g, " ").slice(0, 256));
|
|
216
|
+
appendFileSync(log, `\n===== ${new Date().toISOString()} · ${safeName} (${job.mode}) =====\n`, { mode: 0o600 });
|
|
217
|
+
chmodSync(log, 0o600);
|
|
47
218
|
}
|
|
48
219
|
catch {
|
|
49
220
|
/* logging is best-effort */
|
|
@@ -52,26 +223,146 @@ export function runJobOnce(job) {
|
|
|
52
223
|
// no agent, no tokens, exact. The other modes spawn a fresh hara session. Either way HARA_CRON=1
|
|
53
224
|
// marks the child so cron-run sessions can't create more cron jobs (recursion guard).
|
|
54
225
|
const self = selfArgv();
|
|
55
|
-
|
|
56
|
-
|
|
226
|
+
if (job.mode === "command") {
|
|
227
|
+
const denied = sensitiveShellCommandReason(job.task, job.cwd);
|
|
228
|
+
if (denied) {
|
|
229
|
+
const error = `blocked by protected secret boundary (${denied})`;
|
|
230
|
+
try {
|
|
231
|
+
appendFileSync(log, `[blocked] ${error}\n`);
|
|
232
|
+
}
|
|
233
|
+
catch { /* best effort */ }
|
|
234
|
+
resolve({ ok: false, error, output: "" });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let shell;
|
|
239
|
+
try {
|
|
240
|
+
shell = job.mode === "command" ? shellCommand(job.task, job.cwd, "off") : null;
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
const message = `failed security preflight: ${error instanceof Error ? error.message : String(error)}`;
|
|
244
|
+
try {
|
|
245
|
+
appendFileSync(log, `[blocked] ${message}\n`, { mode: 0o600 });
|
|
246
|
+
}
|
|
247
|
+
catch { /* best effort */ }
|
|
248
|
+
resolve({ ok: false, error: message, output: "" });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const [cmd, argv] = shell
|
|
252
|
+
? [shell.cmd, shell.args]
|
|
57
253
|
: [self[0], [...self.slice(1), ...(job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"])]];
|
|
58
254
|
// HARA_CRON_NAME rides along so the child session's meta gets a human title ("job name · time")
|
|
59
255
|
// instead of the raw prompt (session store's automated-title strategy).
|
|
60
|
-
const
|
|
256
|
+
const env = job.mode === "command"
|
|
257
|
+
? toolSubprocessEnv(process.env, { HARA_CRON: "1" })
|
|
258
|
+
: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name };
|
|
259
|
+
const processGroup = platform() !== "win32";
|
|
260
|
+
let child;
|
|
261
|
+
try {
|
|
262
|
+
child = spawn(cmd, argv, { cwd: job.cwd, env, detached: processGroup });
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
resolve({ ok: false, error: `failed to start: ${error instanceof Error ? error.message : String(error)}`, output: "" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
61
268
|
let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
|
|
62
|
-
|
|
63
|
-
|
|
269
|
+
let logBytes = (() => { try {
|
|
270
|
+
return statSync(log).size;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return 0;
|
|
274
|
+
} })();
|
|
275
|
+
let logCapped = false;
|
|
276
|
+
let done = false;
|
|
277
|
+
let timedOut = false;
|
|
278
|
+
let forceIssued = false;
|
|
279
|
+
let closeBeforeForce = false;
|
|
280
|
+
let cancelTermination;
|
|
281
|
+
const appendSafe = (safe) => {
|
|
282
|
+
tail = (tail + safe).slice(-4_000);
|
|
283
|
+
if (logCapped)
|
|
284
|
+
return;
|
|
64
285
|
try {
|
|
65
|
-
|
|
286
|
+
const bytes = Buffer.byteLength(safe);
|
|
287
|
+
if (logBytes + bytes <= maxLogBytes) {
|
|
288
|
+
appendFileSync(log, safe, { mode: 0o600 });
|
|
289
|
+
logBytes += bytes;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const marker = `\n…[run log capped at ${maxLogBytes} bytes]…\n`;
|
|
293
|
+
appendFileSync(log, marker, { mode: 0o600 });
|
|
294
|
+
logBytes += Buffer.byteLength(marker);
|
|
295
|
+
logCapped = true;
|
|
296
|
+
}
|
|
66
297
|
}
|
|
67
298
|
catch {
|
|
68
299
|
/* ignore */
|
|
69
300
|
}
|
|
70
301
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
302
|
+
// stdout and stderr are separate byte streams; sharing a line buffer can splice unrelated chunks and
|
|
303
|
+
// either corrupt output or create an unsafe synthetic token. Keep an independent redactor per stream.
|
|
304
|
+
const stdout = createToolOutputLineRedactor(appendSafe);
|
|
305
|
+
const stderr = createToolOutputLineRedactor(appendSafe);
|
|
306
|
+
const flush = () => { stdout.flush(); stderr.flush(); };
|
|
307
|
+
const settle = (result) => {
|
|
308
|
+
if (done)
|
|
309
|
+
return;
|
|
310
|
+
done = true;
|
|
311
|
+
clearTimeout(timeoutTimer);
|
|
312
|
+
// Keep a timeout-triggered group KILL scheduled even if the direct child closed on TERM. The default
|
|
313
|
+
// cancellation removes only the hard-settle fallback; it deliberately does not cancel escalation.
|
|
314
|
+
cancelTermination?.();
|
|
315
|
+
flush();
|
|
316
|
+
capLog(log);
|
|
317
|
+
resolve({
|
|
318
|
+
...result,
|
|
319
|
+
error: result.error ? redactToolSubprocessOutput(result.error) : undefined,
|
|
320
|
+
output: tail,
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const timeoutTimer = setTimeout(() => {
|
|
324
|
+
if (done)
|
|
325
|
+
return;
|
|
326
|
+
timedOut = true;
|
|
327
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
328
|
+
processGroup,
|
|
329
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
330
|
+
fallbackMs: 3_000,
|
|
331
|
+
onForce: () => {
|
|
332
|
+
forceIssued = true;
|
|
333
|
+
if (closeBeforeForce)
|
|
334
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
335
|
+
},
|
|
336
|
+
onFallback: () => {
|
|
337
|
+
child.stdout?.destroy();
|
|
338
|
+
child.stderr?.destroy();
|
|
339
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}, timeoutMs);
|
|
343
|
+
child.stdout?.on("data", (d) => { if (!done && !timedOut)
|
|
344
|
+
stdout.push(d.toString()); });
|
|
345
|
+
child.stderr?.on("data", (d) => { if (!done && !timedOut)
|
|
346
|
+
stderr.push(d.toString()); });
|
|
347
|
+
child.on("error", (e) => {
|
|
348
|
+
if (timedOut && !forceIssued) {
|
|
349
|
+
closeBeforeForce = true;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
settle(timedOut
|
|
353
|
+
? { ok: false, error: `timed out after ${timeoutMs}ms`, output: tail }
|
|
354
|
+
: { ok: false, error: String(e?.message ?? e), output: tail });
|
|
355
|
+
});
|
|
356
|
+
child.on("close", (code) => {
|
|
357
|
+
if (timedOut && !forceIssued) {
|
|
358
|
+
closeBeforeForce = true;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (timedOut)
|
|
362
|
+
settle({ ok: false, error: `timed out after ${timeoutMs}ms`, output: tail });
|
|
363
|
+
else
|
|
364
|
+
settle(code === 0 ? { ok: true, output: tail } : { ok: false, error: `exited ${code}`, output: tail });
|
|
365
|
+
});
|
|
75
366
|
});
|
|
76
367
|
}
|
|
77
368
|
/** After a run: push the outcome to the job's deliver channel, and — on repeated failures — a 🚨 alert
|
|
@@ -106,29 +397,77 @@ export async function deliverOutcome(job, r, deliver = deliverResult, nowMs = Da
|
|
|
106
397
|
* overlapping tick (launchd fires every 60s; a job may run longer) skips instead of double-firing.
|
|
107
398
|
* `run` is injectable for tests. Returns the job ids that ran. */
|
|
108
399
|
export async function runTick(nowMs, run = runJobOnce) {
|
|
109
|
-
mkdirSync(cronDir(), { recursive: true });
|
|
400
|
+
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
401
|
+
try {
|
|
402
|
+
chmodSync(cronDir(), 0o700);
|
|
403
|
+
}
|
|
404
|
+
catch { /* best effort */ }
|
|
110
405
|
const lock = lockPath();
|
|
111
|
-
|
|
112
|
-
|
|
406
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
407
|
+
const takeover = takeoverPath();
|
|
408
|
+
let acquired = false;
|
|
409
|
+
const guardState = prepareTakeoverGuard(takeover, nowMs);
|
|
410
|
+
if (guardState === "recovered")
|
|
411
|
+
return { ran: [], skipped: "cleared a stale tick takeover guard; retry on the next tick" };
|
|
412
|
+
if (guardState === "active")
|
|
413
|
+
return { ran: [], skipped: "another tick is taking over a stale lock" };
|
|
414
|
+
// Every ordinary contender checks the takeover guard both before and after O_EXCL creation. The second
|
|
415
|
+
// check covers a contender that was descheduled between its first check and open(2).
|
|
416
|
+
if (!existsSync(takeover) && writeExclusive(lock, token)) {
|
|
417
|
+
if (!existsSync(takeover))
|
|
418
|
+
acquired = true;
|
|
419
|
+
else
|
|
420
|
+
removeOwnedLock(lock, token);
|
|
421
|
+
}
|
|
422
|
+
else if (!existsSync(lock)) {
|
|
423
|
+
// A lock owner may have released between our failed preflight and O_EXCL attempt. Missing a single
|
|
424
|
+
// minute is safer than guessing through an active takeover; the next scheduler tick retries.
|
|
425
|
+
return { ran: [], skipped: "another tick is acquiring the lock" };
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const observedFile = readTickLockFileSnapshot(lock);
|
|
429
|
+
if (!observedFile)
|
|
430
|
+
return { ran: [], skipped: "another tick is in progress" };
|
|
431
|
+
const observed = parseTickLockSnapshot(observedFile);
|
|
432
|
+
const malformedStale = (snapshot) => nowMs - snapshot.mtimeMs >= LOCK_STALE_MS;
|
|
433
|
+
if (observed ? !staleTickLock(observed, nowMs) : !malformedStale(observedFile)) {
|
|
434
|
+
return { ran: [], skipped: "another tick is in progress" };
|
|
435
|
+
}
|
|
436
|
+
const takeoverToken = `${process.pid}:${randomUUID()}`;
|
|
437
|
+
if (!writeExclusive(takeover, takeoverToken))
|
|
438
|
+
return { ran: [], skipped: "another tick is taking over a stale lock" };
|
|
113
439
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
440
|
+
// Re-read under the exclusive guard and require the exact content/inode/size/mtime/ctime diagnosed.
|
|
441
|
+
// A fresh malformed record stays fail-closed; only an unchanged record past the 6h poison window is
|
|
442
|
+
// removable. Never unlink a successor merely because it occupies the same pathname.
|
|
443
|
+
const currentFile = readTickLockFileSnapshot(lock);
|
|
444
|
+
if (currentFile) {
|
|
445
|
+
if (!sameTickLockFile(observedFile, currentFile)) {
|
|
446
|
+
return { ran: [], skipped: "the tick lock changed during stale takeover" };
|
|
447
|
+
}
|
|
448
|
+
const current = parseTickLockSnapshot(currentFile);
|
|
449
|
+
if (observed) {
|
|
450
|
+
if (!sameTickLock(observed, current) || !current || !staleTickLock(current, nowMs)) {
|
|
451
|
+
return { ran: [], skipped: "the tick lock changed during stale takeover" };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else if (current || !malformedStale(currentFile)) {
|
|
455
|
+
return { ran: [], skipped: "the tick lock changed during stale takeover" };
|
|
456
|
+
}
|
|
457
|
+
rmSync(lock);
|
|
122
458
|
}
|
|
123
|
-
|
|
459
|
+
acquired = writeExclusive(lock, token);
|
|
124
460
|
}
|
|
125
|
-
|
|
126
|
-
|
|
461
|
+
finally {
|
|
462
|
+
removeOwnedLock(takeover, takeoverToken);
|
|
127
463
|
}
|
|
128
|
-
if (held)
|
|
129
|
-
return { ran: [], skipped: "another tick is in progress" };
|
|
130
464
|
}
|
|
131
|
-
|
|
465
|
+
if (!acquired)
|
|
466
|
+
return { ran: [], skipped: "another tick is acquiring the lock" };
|
|
467
|
+
try {
|
|
468
|
+
chmodSync(lock, 0o600);
|
|
469
|
+
}
|
|
470
|
+
catch { /* best effort */ }
|
|
132
471
|
try {
|
|
133
472
|
const due = dueJobs(loadJobs(), nowMs);
|
|
134
473
|
const ran = [];
|
|
@@ -141,11 +480,7 @@ export async function runTick(nowMs, run = runJobOnce) {
|
|
|
141
480
|
return { ran };
|
|
142
481
|
}
|
|
143
482
|
finally {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
/* best-effort */
|
|
149
|
-
}
|
|
483
|
+
// Remove only the lock instance we created; never unlink a successor after a stale-lock takeover.
|
|
484
|
+
releasePrimaryTickLock(lock, takeover, token);
|
|
150
485
|
}
|
|
151
486
|
}
|
package/dist/cron/store.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
6
|
+
import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
7
7
|
export function cronDir() {
|
|
8
8
|
return join(homedir(), ".hara", "cron");
|
|
9
9
|
}
|
|
@@ -27,11 +27,19 @@ export function loadJobs() {
|
|
|
27
27
|
}
|
|
28
28
|
/** Persist the job list with an atomic temp-write + rename (never leaves a half-written jobs.json). */
|
|
29
29
|
export function saveJobs(jobs) {
|
|
30
|
-
mkdirSync(cronDir(), { recursive: true });
|
|
30
|
+
mkdirSync(cronDir(), { recursive: true, mode: 0o700 });
|
|
31
|
+
try {
|
|
32
|
+
chmodSync(cronDir(), 0o700);
|
|
33
|
+
}
|
|
34
|
+
catch { /* best effort */ }
|
|
31
35
|
const p = jobsPath();
|
|
32
36
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
33
|
-
writeFileSync(tmp, JSON.stringify(jobs, null, 2) + "\n", "utf8");
|
|
37
|
+
writeFileSync(tmp, JSON.stringify(jobs, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
34
38
|
renameSync(tmp, p);
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(p, 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch { /* best effort */ }
|
|
35
43
|
}
|
|
36
44
|
export function addJob(j) {
|
|
37
45
|
const jobs = loadJobs();
|
package/dist/exec/jobs.js
CHANGED
|
@@ -3,10 +3,25 @@
|
|
|
3
3
|
// optional dep). Same sandbox write-confinement as runShell (shared `shellCommand`). Jobs are the agent's
|
|
4
4
|
// own child processes and are terminated when hara exits.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { platform } from "node:os";
|
|
6
7
|
import { shellCommand } from "../sandbox.js";
|
|
8
|
+
import { createToolOutputLineRedactor, redactToolSubprocessOutput, terminateSubprocessTree, toolSubprocessEnv, } from "../security/subprocess-env.js";
|
|
7
9
|
const MAX_BUF = 64 * 1024; // retain only the tail of each job's combined output
|
|
10
|
+
const MAX_RUNNING_JOBS = 32;
|
|
11
|
+
const MAX_RETAINED_JOBS = 200;
|
|
8
12
|
const jobs = new Map();
|
|
9
13
|
let seq = 0;
|
|
14
|
+
function pruneFinishedJobs(reserve = 0) {
|
|
15
|
+
const target = Math.max(0, MAX_RETAINED_JOBS - reserve);
|
|
16
|
+
if (jobs.size <= target)
|
|
17
|
+
return;
|
|
18
|
+
for (const [id, job] of jobs) {
|
|
19
|
+
if (jobs.size <= target)
|
|
20
|
+
break;
|
|
21
|
+
if (job.status !== "running" && !job.terminationPending)
|
|
22
|
+
jobs.delete(id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
10
25
|
const jobsListeners = new Set();
|
|
11
26
|
/** Subscribe to job start/exit/kill. Lets the UI show "a background task is running" LIVE — crucially at
|
|
12
27
|
* IDLE too: a turn ending doesn't mean the background work did, and without this the user reads the idle
|
|
@@ -37,15 +52,52 @@ function ensureExitCleanup() {
|
|
|
37
52
|
/** Start a background shell job; returns its id immediately. Output is captured to a capped tail buffer. */
|
|
38
53
|
export function startJob(command, cwd, mode) {
|
|
39
54
|
ensureExitCleanup();
|
|
55
|
+
const running = [...jobs.values()].filter((job) => job.status === "running" || job.terminationPending).length;
|
|
56
|
+
if (running >= MAX_RUNNING_JOBS) {
|
|
57
|
+
throw new Error(`too many background jobs (${MAX_RUNNING_JOBS}); stop or wait for an existing job before starting another`);
|
|
58
|
+
}
|
|
59
|
+
pruneFinishedJobs(1);
|
|
40
60
|
const { cmd, args } = shellCommand(command, cwd, mode);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
61
|
+
const processGroup = platform() !== "win32";
|
|
62
|
+
const child = spawn(cmd, args, { cwd, env: toolSubprocessEnv(), detached: processGroup });
|
|
63
|
+
const job = {
|
|
64
|
+
id: "j" + ++seq,
|
|
65
|
+
// The raw command may contain an inline --token/KEY=value. It is used only for spawning above; every
|
|
66
|
+
// stored/listed copy is redacted so /jobs, TUI state, and tool results cannot replay it.
|
|
67
|
+
command: redactToolSubprocessOutput(command),
|
|
68
|
+
child,
|
|
69
|
+
status: "running",
|
|
70
|
+
code: null,
|
|
71
|
+
startedAt: Date.now(),
|
|
72
|
+
buf: "",
|
|
73
|
+
processGroup,
|
|
74
|
+
flushOutput: () => { },
|
|
75
|
+
terminationPending: false,
|
|
76
|
+
closed: false,
|
|
77
|
+
};
|
|
78
|
+
const appendSafe = (safe) => {
|
|
79
|
+
job.buf = (job.buf + safe).slice(-MAX_BUF);
|
|
45
80
|
};
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
// Separate buffers avoid stitching an stdout token prefix to an unrelated stderr suffix. Only redacted
|
|
82
|
+
// text reaches Job.buf; later callers never need access to the raw subprocess stream.
|
|
83
|
+
const stdout = createToolOutputLineRedactor(appendSafe);
|
|
84
|
+
const stderr = createToolOutputLineRedactor(appendSafe);
|
|
85
|
+
let flushed = false;
|
|
86
|
+
job.flushOutput = () => {
|
|
87
|
+
if (flushed)
|
|
88
|
+
return;
|
|
89
|
+
flushed = true;
|
|
90
|
+
stdout.flush();
|
|
91
|
+
stderr.flush();
|
|
92
|
+
};
|
|
93
|
+
child.stdout?.on("data", (d) => stdout.push(d.toString()));
|
|
94
|
+
child.stderr?.on("data", (d) => stderr.push(d.toString()));
|
|
48
95
|
child.on("close", (code) => {
|
|
96
|
+
job.closed = true;
|
|
97
|
+
job.cancelTermination?.();
|
|
98
|
+
if (!job.terminationPending)
|
|
99
|
+
job.cancelTermination = undefined;
|
|
100
|
+
job.flushOutput();
|
|
49
101
|
if (job.status === "running") {
|
|
50
102
|
job.status = "exited";
|
|
51
103
|
job.code = code;
|
|
@@ -53,10 +105,13 @@ export function startJob(command, cwd, mode) {
|
|
|
53
105
|
}
|
|
54
106
|
});
|
|
55
107
|
child.on("error", (e) => {
|
|
108
|
+
job.cancelTermination?.();
|
|
109
|
+
job.cancelTermination = undefined;
|
|
56
110
|
if (job.status === "running") {
|
|
57
111
|
job.status = "exited";
|
|
58
112
|
job.code = -1;
|
|
59
|
-
|
|
113
|
+
appendSafe(`\n[spawn error] ${redactToolSubprocessOutput(e.message)}`);
|
|
114
|
+
job.flushOutput();
|
|
60
115
|
emitJobsChange();
|
|
61
116
|
}
|
|
62
117
|
});
|
|
@@ -80,26 +135,39 @@ export function killJob(id) {
|
|
|
80
135
|
const j = jobs.get(id);
|
|
81
136
|
if (!j || j.status !== "running")
|
|
82
137
|
return false;
|
|
83
|
-
try {
|
|
84
|
-
j.child.kill("SIGTERM");
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
/* already gone */
|
|
88
|
-
}
|
|
89
138
|
j.status = "killed";
|
|
139
|
+
j.terminationPending = true;
|
|
140
|
+
j.cancelTermination = terminateSubprocessTree(j.child, {
|
|
141
|
+
processGroup: j.processGroup,
|
|
142
|
+
graceMs: 250,
|
|
143
|
+
fallbackMs: 1_000,
|
|
144
|
+
onForce: () => {
|
|
145
|
+
j.terminationPending = false;
|
|
146
|
+
if (j.closed)
|
|
147
|
+
j.cancelTermination = undefined;
|
|
148
|
+
},
|
|
149
|
+
onFallback: () => {
|
|
150
|
+
// An intentionally daemonized descendant can escape its original process group while retaining a
|
|
151
|
+
// pipe. Destroy our ends so it cannot keep Hara alive; normal descendants were already group-killed.
|
|
152
|
+
j.child.stdout?.destroy();
|
|
153
|
+
j.child.stderr?.destroy();
|
|
154
|
+
j.flushOutput();
|
|
155
|
+
j.cancelTermination = undefined;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
90
158
|
emitJobsChange();
|
|
91
159
|
return true;
|
|
92
160
|
}
|
|
93
161
|
/** Terminate every running job — registered on process exit so dev servers don't outlive hara. */
|
|
94
162
|
export function killAllJobs() {
|
|
95
163
|
for (const j of jobs.values()) {
|
|
96
|
-
if (j.status === "running") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
164
|
+
if (j.status === "running" || j.terminationPending) {
|
|
165
|
+
j.cancelTermination?.(true);
|
|
166
|
+
j.cancelTermination = undefined;
|
|
167
|
+
// `exit` handlers cannot wait for a grace timer. Force the whole owned tree synchronously so normal
|
|
168
|
+
// quit does not orphan a dev server or worker; Windows taskkill /T supplies the equivalent tree kill.
|
|
169
|
+
terminateSubprocessTree(j.child, { force: true, processGroup: j.processGroup });
|
|
170
|
+
j.terminationPending = false;
|
|
103
171
|
j.status = "killed";
|
|
104
172
|
}
|
|
105
173
|
}
|