@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +62 -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
@@ -30,8 +30,12 @@
30
30
  // tolerates reading it from a legacy config.json (it just maps to the migrated gateway profile).
31
31
  // ────────────────────────────────────────────────────────────────────────────────
32
32
  import { homedir } from "node:os";
33
+ import { spawnSync } from "node:child_process";
33
34
  import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
34
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
35
+ import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, chmodSync, realpathSync, renameSync } from "node:fs";
36
+ import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
37
+ import { atomicWriteText, bindProfilePinWritePath, discardClaimedPath, verifyAtomicWriteBoundary, } from "../fs-write.js";
38
+ import { projectRepositoryTrustedAtStartup } from "../security/project-trust.js";
35
39
  const PERSONAL_ID = "personal";
36
40
  const DEFAULT_ORG_ID = "default-org";
37
41
  function haraDir() {
@@ -149,6 +153,86 @@ export function listProfiles() {
149
153
  // the global default (~/.hara/profiles.json `active`).
150
154
  // ────────────────────────────────────────────────────────────────────────────────
151
155
  const PIN_FILE = ".hara-profile";
156
+ const MAX_PIN_BYTES = 4096;
157
+ const warnedPinFiles = new Set();
158
+ function canonicalExistingDirectory(path) {
159
+ try {
160
+ return realpathSync.native(resolvePath(path));
161
+ }
162
+ catch {
163
+ return resolvePath(path);
164
+ }
165
+ }
166
+ function warnIgnoredPin(file, reason) {
167
+ const key = `${file}:${reason}`;
168
+ if (warnedPinFiles.has(key))
169
+ return;
170
+ warnedPinFiles.add(key);
171
+ const detail = {
172
+ unavailable: "names a profile that is not available",
173
+ tracked: "is tracked by Git and repository identity pins are untrusted by default",
174
+ unverified: "is inside a Git worktree but its tracked status could not be verified",
175
+ invalid: "has an invalid format",
176
+ unsafe: "failed filesystem identity checks",
177
+ }[reason];
178
+ const trustHint = reason === "tracked" || reason === "unverified"
179
+ ? " Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust."
180
+ : "";
181
+ try {
182
+ // The directory path is repository input and may itself contain token-shaped text. The fixed basename
183
+ // is enough to identify the feature without reflecting any attacker-controlled path or file content.
184
+ process.stderr.write(`hara: ignored .hara-profile: it ${detail}.${trustHint} Run \`hara profile pin <id>\` or \`hara profile unpin\` to fix.\n`);
185
+ }
186
+ catch {
187
+ /* best effort */
188
+ }
189
+ }
190
+ function gitMarkerAbove(start) {
191
+ let dir = start;
192
+ for (let depth = 0; depth < 128; depth++) {
193
+ try {
194
+ lstatSync(join(dir, ".git"));
195
+ return true;
196
+ }
197
+ catch (error) {
198
+ if (error?.code !== "ENOENT")
199
+ return true; // unreadable/suspicious marker: require a successful Git check
200
+ }
201
+ const parent = dirname(dir);
202
+ if (parent === dir)
203
+ return false;
204
+ dir = parent;
205
+ }
206
+ return true; // fail closed on a pathological path depth
207
+ }
208
+ /** Check only the index, with no shell and no repository-controlled command string. Unknown/error is distinct
209
+ * from untracked so a missing/failed git binary can never silently bless a committed identity pin. */
210
+ function pinTracking(file) {
211
+ const dir = dirname(file);
212
+ if (!gitMarkerAbove(dir))
213
+ return "outside-git";
214
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
215
+ env.GIT_OPTIONAL_LOCKS = "0";
216
+ env.GIT_TERMINAL_PROMPT = "0";
217
+ const result = spawnSync("git", [
218
+ "-c", "core.fsmonitor=false",
219
+ "-c", "core.untrackedCache=false",
220
+ "-C", dir,
221
+ "ls-files", "--error-unmatch", "--", PIN_FILE,
222
+ ], {
223
+ env,
224
+ stdio: "ignore",
225
+ timeout: 2000,
226
+ windowsHide: true,
227
+ });
228
+ if (result.error || result.signal)
229
+ return "unknown";
230
+ if (result.status === 0)
231
+ return "tracked";
232
+ if (result.status === 1)
233
+ return "untracked";
234
+ return "unknown";
235
+ }
152
236
  export function pinFilePath(dir) {
153
237
  return join(dir, PIN_FILE);
154
238
  }
@@ -157,31 +241,46 @@ export function pinFilePath(dir) {
157
241
  * If a pin file exists but names an unknown profile, we emit a one-line stderr warn
158
242
  * and return null (non-fatal — the active resolution falls through to the next layer). */
159
243
  export function findPinnedProfile(startDir) {
160
- const home = homedir();
161
- const { root } = parsePath(startDir);
162
- let dir = resolvePath(startDir);
244
+ const home = canonicalExistingDirectory(homedir());
245
+ let dir = canonicalExistingDirectory(startDir);
246
+ const { root } = parsePath(dir);
163
247
  // Track visited to defend against pathological symlink loops (best-effort).
164
248
  const seen = new Set();
165
249
  while (!seen.has(dir)) {
166
250
  seen.add(dir);
167
251
  const file = pinFilePath(dir);
168
- if (existsSync(file)) {
169
- try {
170
- const id = readFileSync(file, "utf8").split(/\r?\n/)[0].trim();
171
- if (id && getProfile(id))
172
- return { id, file };
173
- if (id) {
174
- // pin points to a profile that no longer exists — warn once, fall through.
175
- try {
176
- process.stderr.write(`hara: ${file} pins profile '${id}', but it doesn't exist — falling back. Run \`hara profile pin <id>\` or \`hara profile unpin\` to fix.\n`);
177
- }
178
- catch {
179
- /* ignore */
180
- }
252
+ try {
253
+ const snapshot = readVerifiedRegularFileSnapshotSync(file, MAX_PIN_BYTES, {
254
+ action: "read profile pin",
255
+ protectSensitive: false,
256
+ rejectHardLinks: true,
257
+ });
258
+ if (snapshot.text.includes("\0")) {
259
+ warnIgnoredPin(file, "invalid");
260
+ return null;
261
+ }
262
+ if (!projectRepositoryTrustedAtStartup()) {
263
+ const tracking = pinTracking(file);
264
+ if (tracking === "tracked") {
265
+ warnIgnoredPin(file, "tracked");
266
+ return null;
267
+ }
268
+ if (tracking === "unknown") {
269
+ warnIgnoredPin(file, "unverified");
270
+ return null;
181
271
  }
182
272
  }
183
- catch {
184
- /* unreadable pin — skip */
273
+ const id = snapshot.text.split(/\r?\n/)[0].trim();
274
+ if (id && getProfile(id))
275
+ return { id, file };
276
+ if (id)
277
+ warnIgnoredPin(file, "unavailable");
278
+ return null;
279
+ }
280
+ catch (error) {
281
+ if (error?.code !== "ENOENT") {
282
+ warnIgnoredPin(file, error?.code === "HARA_FILE_TOO_LARGE" ? "invalid" : "unsafe");
283
+ return null;
185
284
  }
186
285
  }
187
286
  // Stop walking once we're at $HOME or the fs root (don't escape into shared parent dirs).
@@ -194,24 +293,50 @@ export function findPinnedProfile(startDir) {
194
293
  }
195
294
  return null;
196
295
  }
197
- /** Write `.hara-profile` in the given dir with `id` as the only line. */
198
- export function writePin(dir, id) {
296
+ /** Atomically write `.hara-profile` in the given dir with `id` as the only line. */
297
+ export async function writePin(dir, id) {
199
298
  if (!getProfile(id))
200
299
  throw new Error(`no profile '${id}' — list with \`hara profile list\``);
201
300
  const file = pinFilePath(dir);
202
- writeFileSync(file, id + "\n", "utf8");
203
- return { file };
301
+ const boundary = bindProfilePinWritePath(file);
302
+ let snapshot = null;
303
+ try {
304
+ snapshot = readVerifiedRegularFileSnapshotSync(boundary.target, MAX_PIN_BYTES, {
305
+ action: "write profile pin",
306
+ protectSensitive: false,
307
+ rejectHardLinks: true,
308
+ });
309
+ }
310
+ catch (error) {
311
+ if (error?.code !== "ENOENT")
312
+ throw new Error("refusing to replace an unsafe profile pin");
313
+ }
314
+ await atomicWriteText(boundary.target, id + "\n", {
315
+ expected: snapshot?.text ?? null,
316
+ expectedIdentity: snapshot ?? undefined,
317
+ boundary,
318
+ mode: 0o600,
319
+ });
320
+ return { file: boundary.target };
204
321
  }
205
322
  /** Remove `.hara-profile` from the given dir. Returns true if it was there. */
206
323
  export function removePin(dir) {
207
324
  const file = pinFilePath(dir);
208
- if (!existsSync(file))
209
- return false;
325
+ let boundary;
210
326
  try {
211
- unlinkSync(file);
327
+ boundary = bindProfilePinWritePath(file, "remove profile pin");
328
+ const snapshot = readVerifiedRegularFileSnapshotSync(boundary.target, MAX_PIN_BYTES, {
329
+ action: "remove profile pin",
330
+ protectSensitive: false,
331
+ rejectHardLinks: true,
332
+ });
333
+ verifyAtomicWriteBoundary(boundary);
334
+ discardClaimedPath(boundary.target, snapshot);
212
335
  return true;
213
336
  }
214
- catch {
337
+ catch (error) {
338
+ if (error?.code !== "ENOENT")
339
+ warnIgnoredPin(boundary?.target ?? file, "unsafe");
215
340
  return false;
216
341
  }
217
342
  }
package/dist/recall.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // Phase-C v0: lexical-first (no embeddings); reuses the shared filesystem walker.
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { readFileSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
6
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
7
7
  import { walkFiles } from "./fs-walk.js";
8
8
  import { skillsDirs } from "./skills/skills.js";
9
+ import { readModelContextFileSync } from "./fs-read.js";
10
+ const MAX_RECALL_SOURCE_BYTES = 256 * 1024;
9
11
  export function assetsDir() {
10
12
  return process.env.HARA_ASSETS || join(homedir(), ".hara", "code-assets");
11
13
  }
@@ -56,7 +58,7 @@ export function searchAssets(query, limit = 5, roots) {
56
58
  for (const rel of walkFiles(dir).filter((f) => f.endsWith(".md"))) {
57
59
  let text;
58
60
  try {
59
- text = readFileSync(join(dir, rel), "utf8");
61
+ text = readModelContextFileSync(join(dir, rel), MAX_RECALL_SOURCE_BYTES);
60
62
  }
61
63
  catch {
62
64
  continue;
@@ -0,0 +1,37 @@
1
+ export const MIN_NODE_MAJOR = 22;
2
+ // Commander 15 (a direct runtime dependency) requires this exact floor. Keep package engines, startup,
3
+ // doctor, and docs aligned so an early Node 22 release gets an upgrade hint before dependencies load.
4
+ export const MIN_NODE_VERSION = "22.12.0";
5
+ function supportedNodeVersion(version) {
6
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
7
+ if (!match)
8
+ return false;
9
+ const current = match.slice(1).map(Number);
10
+ const minimum = MIN_NODE_VERSION.split(".").map(Number);
11
+ for (let index = 0; index < minimum.length; index++) {
12
+ if (current[index] > minimum[index])
13
+ return true;
14
+ if (current[index] < minimum[index])
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+ /** Bun powers Hara's standalone binaries and does not require a host Node installation. */
20
+ export function unsupportedNodeMessage(versions = process.versions) {
21
+ if (versions.bun)
22
+ return null;
23
+ const version = String(versions.node ?? "unknown");
24
+ if (supportedNodeVersion(version))
25
+ return null;
26
+ const major = Number.parseInt(version, 10);
27
+ const detail = major === MIN_NODE_MAJOR
28
+ ? `This Node.js ${MIN_NODE_MAJOR} release is below Hara's supported ${MIN_NODE_VERSION} floor.`
29
+ : `This Node.js release is below Hara's supported ${MIN_NODE_VERSION} floor.`;
30
+ return [
31
+ `Hara requires Node.js ${MIN_NODE_VERSION} or newer (detected ${version}).`,
32
+ detail,
33
+ `Upgrade with: nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR}`,
34
+ "Or install the standalone Hara binary, which does not require Node.js:",
35
+ " curl -fsSL https://raw.githubusercontent.com/hara-cli/hara/main/install.sh | sh",
36
+ ].join("\n");
37
+ }
package/dist/sandbox.js CHANGED
@@ -1,4 +1,6 @@
1
- // OS sandboxing for the bash tool WRITE CONFINEMENT, not a security jail. macOS = Seatbelt
1
+ // OS sandboxing for the bash tool. macOS Seatbelt provides write confinement plus a narrow protected-file
2
+ // read mask. Other reads, network, and process exec are not generally confined; non-macOS shells have only
3
+ // Hara's command preflight and must not be described as a kernel-enforced read sandbox.
2
4
  // (sandbox-exec) restricting `file-write*` to the workspace (workspace-write) or to nothing outside
3
5
  // temp (read-only). Reads, network, and process exec are NOT restricted, and /private/tmp +
4
6
  // /private/var/folders stay writable in every mode — so this stops a stray `rm`/overwrite escaping the
@@ -6,9 +8,9 @@
6
8
  // emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
7
9
  // shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
8
10
  import { spawn, spawnSync } from "node:child_process";
9
- import { writeFileSync, mkdtempSync } from "node:fs";
10
- import { tmpdir, platform } from "node:os";
11
- import { join } from "node:path";
11
+ import { platform } from "node:os";
12
+ import { existingSensitiveSeatbeltMasks, sensitiveFilesAllowed, sensitiveShellCommandReason, } from "./security/sensitive-files.js";
13
+ import { terminateSubprocessTree, toolSubprocessEnv } from "./security/subprocess-env.js";
12
14
  // Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
13
15
  // `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
14
16
  // have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
@@ -34,8 +36,15 @@ export function resolveShellArgv(command, plat, bash) {
34
36
  }
35
37
  return { cmd: "/bin/sh", args: ["-c", command] };
36
38
  }
37
- const sbQuote = (s) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
39
+ const MAX_INLINE_SEATBELT_PROFILE_BYTES = 96 * 1024;
40
+ const sbQuote = (s) => {
41
+ if (/[\u0000-\u001f\u007f]/u.test(s)) {
42
+ throw new Error("cannot safely encode a protected path containing control characters in a Seatbelt profile");
43
+ }
44
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
45
+ };
38
46
  function seatbeltProfile(cwd, mode) {
47
+ const rules = ["(version 1)", "(allow default)"];
39
48
  const writable = [
40
49
  '(literal "/dev/null")',
41
50
  '(literal "/dev/stdout")',
@@ -45,9 +54,38 @@ function seatbeltProfile(cwd, mode) {
45
54
  '(subpath "/private/tmp")',
46
55
  '(subpath "/private/var/folders")',
47
56
  ];
48
- if (mode === "workspace-write")
49
- writable.push(`(subpath ${sbQuote(cwd)})`);
50
- return `(version 1)\n(allow default)\n(deny file-write*)\n(allow file-write*\n ${writable.join("\n ")})\n`;
57
+ if (mode !== "off") {
58
+ if (mode === "workspace-write")
59
+ writable.push(`(subpath ${sbQuote(cwd)})`);
60
+ rules.push(`(deny file-write*)\n(allow file-write*\n ${writable.join("\n ")})`);
61
+ }
62
+ const masks = existingSensitiveSeatbeltMasks(cwd);
63
+ const unreadable = masks.files;
64
+ if (unreadable.length) {
65
+ const literals = unreadable.map((path) => `(literal ${sbQuote(path)})`).join("\n ");
66
+ rules.push(`(deny file-read*\n ${literals})`);
67
+ // Protect metadata mutations too: unlink/rename/link/chmod all sit under file-write* in Seatbelt.
68
+ // This remains active in sandbox=off; the opt-out is the explicit launch-time secret-file waiver.
69
+ rules.push(`(deny file-write*\n ${literals})`);
70
+ }
71
+ const unreadableDirs = masks.directories;
72
+ if (unreadableDirs.length) {
73
+ // `subpath` masks descendants; the paired `literal` masks rename/unlink of the directory entry itself.
74
+ const subpaths = unreadableDirs
75
+ .flatMap((path) => [`(literal ${sbQuote(path)})`, `(subpath ${sbQuote(path)})`])
76
+ .join("\n ");
77
+ rules.push(`(deny file-read*\n ${subpaths})`);
78
+ rules.push(`(deny file-write*\n ${subpaths})`);
79
+ }
80
+ const writeContainers = masks.writeContainers;
81
+ if (writeContainers.length) {
82
+ rules.push(`(deny file-write*\n ${writeContainers.map((path) => `(literal ${sbQuote(path)})`).join("\n ")})`);
83
+ }
84
+ const profile = rules.join("\n") + "\n";
85
+ if (Buffer.byteLength(profile) > MAX_INLINE_SEATBELT_PROFILE_BYTES) {
86
+ throw new Error(`protected-file Seatbelt profile exceeds ${MAX_INLINE_SEATBELT_PROFILE_BYTES} bytes; refusing an incomplete or unlaunchable mask`);
87
+ }
88
+ return profile;
51
89
  }
52
90
  export function sandboxSupported() {
53
91
  return platform() === "darwin";
@@ -61,11 +99,17 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
61
99
  /** Build the (sandboxed, when supported) argv for a shell command — shared by runShell + background jobs
62
100
  * so the seatbelt write-confinement is identical for both. */
63
101
  export function shellCommand(command, cwd, mode) {
64
- if (mode !== "off" && platform() === "darwin") {
65
- const dir = mkdtempSync(join(tmpdir(), "hara-sb-"));
66
- const profileFile = join(dir, "policy.sb");
67
- writeFileSync(profileFile, seatbeltProfile(cwd, mode));
68
- return { cmd: "sandbox-exec", args: ["-f", profileFile, "/bin/bash", "-lc", command] };
102
+ const protectedReason = sensitiveShellCommandReason(command, cwd);
103
+ if (protectedReason) {
104
+ throw new Error(`shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
105
+ "Restart hara with HARA_ALLOW_SENSITIVE_FILES=1 only for an intentional, user-approved exposure.");
106
+ }
107
+ // sandbox=off still retains the narrow secret-file read mask on macOS. It places no restriction on
108
+ // ordinary reads/writes/network and is removed only by the explicit launch-time sensitive-file opt-in.
109
+ if ((mode !== "off" || !sensitiveFilesAllowed()) && platform() === "darwin") {
110
+ // Non-login shell: inheriting the already-scrubbed PATH is sufficient, while -l would source the
111
+ // user's profile again and silently re-introduce credentials we deliberately removed from env.
112
+ return { cmd: "sandbox-exec", args: ["-p", seatbeltProfile(cwd, mode), "/bin/bash", "-c", command] };
69
113
  }
70
114
  maybeWarnUnsandboxed(mode);
71
115
  const plat = platform();
@@ -97,54 +141,119 @@ export function runShell(command, cwd, mode, opts) {
97
141
  // https op against a private repo would otherwise sit silently until the timeout (observed as
98
142
  // "git hangs 5 minutes"). With prompts disabled it fails in seconds with a real auth error.
99
143
  // Users' credential helpers (keychain/GCM store) still work — only interactive PROMPTS are off.
100
- const env = {
101
- ...process.env,
144
+ const env = toolSubprocessEnv(process.env, {
102
145
  GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT ?? "0",
103
146
  GCM_INTERACTIVE: process.env.GCM_INTERACTIVE ?? "never",
104
- };
105
- const child = spawn(cmd, args, { cwd, env });
147
+ });
148
+ // A dedicated POSIX process group lets timeout/output-cap termination reach grandchildren (shell →
149
+ // node/npm → worker). Windows uses taskkill /T in terminateSubprocessTree instead.
150
+ const processGroup = platform() !== "win32";
151
+ const child = spawn(cmd, args, { cwd, env, detached: processGroup });
106
152
  let stdout = "";
107
153
  let stderr = "";
108
154
  let timedOut = false;
109
155
  let killedForSize = false;
110
- const grow = (cur, add) => (cur.length < opts.maxBuffer ? cur + add : cur);
156
+ let done = false;
157
+ let receivedBytes = 0;
158
+ let forceIssued = false;
159
+ let closeBeforeForce = false;
160
+ let closeBeforeForceCode = null;
161
+ let cancelTermination;
162
+ const grow = (cur, add) => {
163
+ const remaining = opts.maxBuffer - stdout.length - stderr.length;
164
+ if (remaining <= 0)
165
+ return cur;
166
+ return cur + add.slice(0, remaining);
167
+ };
168
+ const hardSettle = () => {
169
+ // A daemon can intentionally create a new process group while retaining our pipes. We cannot signal
170
+ // that unknown group safely, but we can destroy our pipe ends and guarantee the API settles on time.
171
+ child.stdout.destroy();
172
+ child.stderr.destroy();
173
+ settle(null);
174
+ };
175
+ const stopTree = () => {
176
+ if (cancelTermination)
177
+ return;
178
+ cancelTermination = terminateSubprocessTree(child, {
179
+ processGroup,
180
+ graceMs: 250,
181
+ fallbackMs: 1_000,
182
+ onFallback: hardSettle,
183
+ onForce: () => {
184
+ forceIssued = true;
185
+ // A quiet descendant can let the direct shell close after TERM while remaining alive itself.
186
+ // Do not claim the timed-out/capped tree is gone until the forced group kill has been issued.
187
+ if (closeBeforeForce)
188
+ settle(closeBeforeForceCode);
189
+ },
190
+ });
191
+ };
192
+ const settle = (code, error) => {
193
+ if (done)
194
+ return;
195
+ done = true;
196
+ clearTimeout(timer);
197
+ cancelTermination?.();
198
+ if (error) {
199
+ reject(Object.assign(error, { stdout, stderr }));
200
+ return;
201
+ }
202
+ if (killedForSize) {
203
+ resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process tree killed]` });
204
+ }
205
+ else if (timedOut) {
206
+ reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
207
+ }
208
+ else if (code !== 0) {
209
+ reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
210
+ }
211
+ else {
212
+ resolve({ stdout, stderr });
213
+ }
214
+ };
111
215
  const timer = setTimeout(() => {
112
216
  timedOut = true;
113
- child.kill("SIGKILL");
217
+ stopTree();
114
218
  }, opts.timeout);
115
219
  // Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
116
220
  // until the timeout just because we stopped retaining the bytes.
117
221
  const checkOverflow = () => {
118
- if (!killedForSize && stdout.length + stderr.length >= opts.maxBuffer) {
222
+ if (!killedForSize && receivedBytes > opts.maxBuffer) {
119
223
  killedForSize = true;
120
- child.kill("SIGKILL");
224
+ stopTree();
121
225
  }
122
226
  };
123
227
  child.stdout.on("data", (d) => {
228
+ if (done || timedOut || killedForSize)
229
+ return;
124
230
  const s = d.toString();
231
+ receivedBytes += d.length;
125
232
  stdout = grow(stdout, s);
126
- opts.onData?.(s);
233
+ if (receivedBytes <= opts.maxBuffer)
234
+ opts.onData?.(s, "stdout");
127
235
  checkOverflow();
128
236
  });
129
237
  child.stderr.on("data", (d) => {
238
+ if (done || timedOut || killedForSize)
239
+ return;
130
240
  const s = d.toString();
241
+ receivedBytes += d.length;
131
242
  stderr = grow(stderr, s);
132
- opts.onData?.(s);
243
+ if (receivedBytes <= opts.maxBuffer)
244
+ opts.onData?.(s, "stderr");
133
245
  checkOverflow();
134
246
  });
135
247
  child.on("error", (e) => {
136
- clearTimeout(timer);
137
- reject(Object.assign(e, { stdout, stderr }));
248
+ settle(null, e);
138
249
  });
139
250
  child.on("close", (code) => {
140
- clearTimeout(timer);
141
- if (killedForSize)
142
- return resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process killed]` });
143
- if (timedOut)
144
- return reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
145
- if (code !== 0)
146
- return reject(Object.assign(new Error(`exit code ${code}`), { stdout, stderr, code }));
147
- resolve({ stdout, stderr });
251
+ if ((timedOut || killedForSize) && !forceIssued) {
252
+ closeBeforeForce = true;
253
+ closeBeforeForceCode = code;
254
+ return;
255
+ }
256
+ settle(code);
148
257
  });
149
258
  });
150
259
  }