@nanhara/hara 0.122.0 → 0.122.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
@@ -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, writeFileSync, rmSync, statSync, appendFileSync, readFileSync } from "node:fs";
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
- mkdirSync(join(cronDir(), "logs"), { recursive: true });
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
- appendFileSync(log, `\n===== ${new Date().toISOString()} · ${job.name} (${job.mode}) =====\n`);
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
- const [cmd, argv] = job.mode === "command"
56
- ? ["bash", ["-lc", job.task]]
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 child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name } });
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
- const append = (d) => {
63
- tail = (tail + d.toString()).slice(-4_000);
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
- appendFileSync(log, d);
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
- child.stdout.on("data", append);
72
- child.stderr.on("data", append);
73
- child.on("error", (e) => resolve({ ok: false, error: String(e?.message ?? e), output: tail }));
74
- child.on("close", (code) => resolve(code === 0 ? { ok: true, output: tail } : { ok: false, error: `exited ${code}`, output: tail }));
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
- if (existsSync(lock)) {
112
- let held = false;
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
- const fresh = nowMs - statSync(lock).mtimeMs < LOCK_STALE_MS;
115
- const pid = Number(readFileSync(lock, "utf8").trim());
116
- let alive = false;
117
- try {
118
- alive = pid > 0 && (process.kill(pid, 0), true); // signal 0 = liveness probe
119
- }
120
- catch {
121
- alive = false; // ESRCH → the tick that wrote this lock is gone
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
- held = fresh && alive; // respect a lock only if it's recent AND owned by a live process (no crash-poison)
459
+ acquired = writeExclusive(lock, token);
124
460
  }
125
- catch {
126
- held = false; // unreadable lock → proceed
461
+ finally {
462
+ removeOwnedLock(takeover, takeoverToken);
127
463
  }
128
- if (held)
129
- return { ran: [], skipped: "another tick is in progress" };
130
464
  }
131
- writeFileSync(lock, String(process.pid));
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
- try {
145
- rmSync(lock);
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
  }
@@ -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 child = spawn(cmd, args, { cwd });
42
- const job = { id: "j" + ++seq, command, child, status: "running", code: null, startedAt: Date.now(), buf: "" };
43
- const onData = (d) => {
44
- job.buf = (job.buf + d.toString()).slice(-MAX_BUF);
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
- child.stdout?.on("data", onData);
47
- child.stderr?.on("data", onData);
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
- job.buf = (job.buf + `\n[spawn error] ${e.message}`).slice(-MAX_BUF);
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
- try {
98
- j.child.kill("SIGTERM");
99
- }
100
- catch {
101
- /* ignore */
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
  }