@nanhara/hara 0.124.0 → 0.124.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.
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
8
8
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
9
9
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { verifyOpenedRegularFileSync } from "../fs-read.js";
11
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
11
12
  import { sensitiveFileReason } from "../security/sensitive-files.js";
12
13
  import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
13
14
  const MAX_CHANGED_PATHS = 4096;
@@ -125,8 +126,7 @@ function stagedIndexEntry(cwd, path) {
125
126
  }
126
127
  function verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) {
127
128
  const absolute = join(cwd, path);
128
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
129
- const fd = openSync(absolute, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
129
+ const fd = openSync(absolute, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
130
130
  try {
131
131
  const before = fstatSync(fd);
132
132
  verifyOpenedRegularFileSync(absolute, before, {
@@ -16,6 +16,7 @@ import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
16
16
  import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
17
17
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
18
18
  import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
19
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
19
20
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
20
21
  const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
21
22
  const projectPermissionWarnings = new Set();
@@ -472,7 +473,7 @@ function scaffoldProjectPermissions(cwd) {
472
473
  fd = undefined;
473
474
  verifiedDirectory(parent, parentIdentity);
474
475
  const staged = lstatSync(temp);
475
- if (!staged.isFile() || staged.isSymbolicLink() || staged.dev !== tempIdentity.dev || staged.ino !== tempIdentity.ino) {
476
+ if (!staged.isFile() || staged.isSymbolicLink() || !sameOpenedFileIdentity(staged, tempIdentity)) {
476
477
  throw new Error("refusing project permissions write: staging file identity changed");
477
478
  }
478
479
  verifiedDirectory(parent, parentIdentity);
@@ -490,7 +491,7 @@ function scaffoldProjectPermissions(cwd) {
490
491
  }
491
492
  verifiedDirectory(parent, parentIdentity);
492
493
  const written = lstatSync(target);
493
- if (!written.isFile() || written.isSymbolicLink() || written.dev !== tempIdentity.dev || written.ino !== tempIdentity.ino) {
494
+ if (!written.isFile() || written.isSymbolicLink() || !sameOpenedFileIdentity(written, tempIdentity)) {
494
495
  throw new Error("refusing project permissions write: committed file identity changed");
495
496
  }
496
497
  unlinkSync(temp);
@@ -507,7 +508,7 @@ function scaffoldProjectPermissions(cwd) {
507
508
  try {
508
509
  verifiedDirectory(parent, parentIdentity);
509
510
  const current = lstatSync(temp);
510
- if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino)
511
+ if (current.isFile() && sameOpenedFileIdentity(current, tempIdentity))
511
512
  unlinkSync(temp);
512
513
  }
513
514
  catch {
@@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, join, resolve } from "node:path";
8
8
  import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
9
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
9
11
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
10
12
  const tightenedHomes = new Set();
11
13
  const DEFAULT_MIGRATION_CAP = 50_000;
@@ -64,9 +66,10 @@ function verifyAndTightenPrivateDirectory(path) {
64
66
  // no POSIX ownership mode to repair
65
67
  }
66
68
  else {
67
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
68
- const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
69
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
69
+ const fd = openSync(path, constants.O_RDONLY
70
+ | optionalPosixOpenFlag("O_NONBLOCK")
71
+ | optionalPosixOpenFlag("O_NOFOLLOW")
72
+ | optionalPosixOpenFlag("O_DIRECTORY"));
70
73
  try {
71
74
  const opened = fstatSync(fd);
72
75
  if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
@@ -173,8 +176,7 @@ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_
173
176
  }
174
177
  if (before.isSymbolicLink())
175
178
  throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
176
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
177
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
179
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
178
180
  try {
179
181
  let info = fstatSync(fd);
180
182
  verifyOpenedRegularFileSync(path, info, {
@@ -227,10 +229,39 @@ function samePrivateFile(path, expected) {
227
229
  const info = lstatSync(path);
228
230
  return (info.isFile()
229
231
  && !info.isSymbolicLink()
230
- && info.dev === expected.dev
231
- && info.ino === expected.ino
232
- && (info.mode & 0o777) === expected.mode
233
- && info.nlink === expected.nlink);
232
+ && privateStateFileIdentityMatches({
233
+ dev: info.dev,
234
+ ino: info.ino,
235
+ mode: info.mode & 0o777,
236
+ nlink: info.nlink,
237
+ }, expected));
238
+ }
239
+ /**
240
+ * Compare the fields Node can use as a stable file identity on the current platform. Windows only exposes
241
+ * owner read/write permission semantics, so its synthetic POSIX mode can differ between descriptor- and
242
+ * path-based stats without identifying a different file. File type, dev/ino and hard-link count remain
243
+ * mandatory there; POSIX additionally requires the exact owner-only mode.
244
+ */
245
+ export function privateStateFileIdentityMatches(actual, expected, platform = process.platform) {
246
+ return (sameOpenedFileIdentity(actual, expected, platform)
247
+ && actual.nlink === expected.nlink
248
+ && (platform === "win32" || actual.mode === expected.mode));
249
+ }
250
+ function privateFileIdentitySummary(path) {
251
+ try {
252
+ const info = lstatSync(path);
253
+ return JSON.stringify({
254
+ file: info.isFile(),
255
+ symlink: info.isSymbolicLink(),
256
+ dev: info.dev,
257
+ ino: info.ino,
258
+ mode: info.mode & 0o777,
259
+ nlink: info.nlink,
260
+ });
261
+ }
262
+ catch (error) {
263
+ return JSON.stringify({ error: error?.code ?? error?.message ?? String(error) });
264
+ }
234
265
  }
235
266
  function restorePrivateClaim(claimed, target, expected) {
236
267
  if (!samePrivateFile(claimed, expected)) {
@@ -253,9 +284,10 @@ function restorePrivateClaim(claimed, target, expected) {
253
284
  }
254
285
  function syncPrivateDirectory(path) {
255
286
  try {
256
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
257
- const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
258
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
287
+ const fd = openSync(path, constants.O_RDONLY
288
+ | optionalPosixOpenFlag("O_NONBLOCK")
289
+ | optionalPosixOpenFlag("O_NOFOLLOW")
290
+ | optionalPosixOpenFlag("O_DIRECTORY"));
259
291
  try {
260
292
  if (fstatSync(fd).isDirectory())
261
293
  fsyncSync(fd);
@@ -281,11 +313,12 @@ export function writePrivateStateFileSync(binding, text) {
281
313
  const existing = readPrivateStateFileSnapshotSync(path);
282
314
  verifyPrivateDirectory(directory);
283
315
  const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
284
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
285
316
  let fd;
286
317
  let staged;
287
318
  try {
288
- fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
319
+ // `wx` maps to O_WRONLY|O_CREAT|O_EXCL/CREATE_NEW and refuses an already-present symlink or file.
320
+ // Bun's Windows numeric-open compatibility can otherwise turn this valid create into a false ENOENT.
321
+ fd = openSync(temp, "wx", 0o600);
289
322
  writeFileSync(fd, text, "utf8");
290
323
  try {
291
324
  fchmodSync(fd, 0o600);
@@ -332,8 +365,7 @@ export function writePrivateStateFileSync(binding, text) {
332
365
  try {
333
366
  const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
334
367
  verifiedClaim = Boolean(claimedSnapshot
335
- && claimedSnapshot.dev === existing.dev
336
- && claimedSnapshot.ino === existing.ino
368
+ && sameOpenedFileIdentity(claimedSnapshot, existing)
337
369
  && claimedSnapshot.mode === existing.mode
338
370
  && claimedSnapshot.nlink === existing.nlink
339
371
  && claimedSnapshot.text === existing.text);
@@ -376,15 +408,17 @@ export function writePrivateStateFileSync(binding, text) {
376
408
  }
377
409
  const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
378
410
  if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
379
- throw new Error(`private Hara state staging identity changed during commit: '${path}'`);
411
+ throw new Error(`private Hara state staging identity changed during commit: '${path}'`
412
+ + `; expected=${JSON.stringify(linkedStaged)}`
413
+ + `; staging=${privateFileIdentitySummary(temp)}`
414
+ + `; target=${privateFileIdentitySummary(path)}`);
380
415
  }
381
416
  unlinkSync(temp);
382
417
  const committed = lstatSync(path);
383
418
  if (!staged
384
419
  || !committed.isFile()
385
420
  || committed.isSymbolicLink()
386
- || committed.dev !== staged.dev
387
- || committed.ino !== staged.ino
421
+ || !sameOpenedFileIdentity(committed, staged)
388
422
  || committed.nlink !== 1
389
423
  || (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
390
424
  throw new Error(`private Hara state file changed during commit: '${path}'`);
@@ -489,9 +523,8 @@ export function removePrivateStateFile(path, expected, directory) {
489
523
  if (!current.isFile()
490
524
  || current.isSymbolicLink()
491
525
  || current.nlink !== 1
492
- || current.dev !== expected.dev
493
- || current.ino !== expected.ino
494
- || (current.mode & 0o777) !== expected.mode
526
+ || !sameOpenedFileIdentity(current, expected)
527
+ || (process.platform !== "win32" && (current.mode & 0o777) !== expected.mode)
495
528
  || current.size !== expected.size
496
529
  || current.mtimeMs !== expected.mtimeMs
497
530
  || current.ctimeMs !== expected.ctimeMs)
@@ -32,6 +32,8 @@ import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
32
32
  import { SessionHub, realStore } from "./sessions.js";
33
33
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
34
34
  import { readModelContextFileSync } from "../fs-read.js";
35
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
36
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
35
37
  import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
36
38
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
37
39
  const COMPACT_TIMEOUT_MS = 60_000;
@@ -54,7 +56,7 @@ const ensurePrivateDiscoveryDir = (dir) => {
54
56
  mkdirSync(dir, { recursive: true, mode: 0o700 });
55
57
  let fd;
56
58
  try {
57
- fd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
59
+ fd = openSync(dir, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_DIRECTORY") | optionalPosixOpenFlag("O_NOFOLLOW"));
58
60
  const st = fstatSync(fd);
59
61
  if (!st.isDirectory())
60
62
  throw new Error(`${dir} must be a private directory, not a symlink`);
@@ -151,7 +153,7 @@ const writeDiscovery = async (dir, path, record) => {
151
153
  const temp = join(dir, `.serve.json.${process.pid}.${record.instanceId}.tmp`);
152
154
  let fd;
153
155
  try {
154
- fd = openSync(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, 0o600);
156
+ fd = openSync(temp, "wx", 0o600);
155
157
  fchmodSync(fd, 0o600);
156
158
  writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
157
159
  fsyncSync(fd);
@@ -177,7 +179,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
177
179
  await withDiscoveryLock(dir, record.instanceId, () => {
178
180
  let fd;
179
181
  try {
180
- fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
182
+ fd = openSync(path, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_NOFOLLOW"));
181
183
  const opened = fstatSync(fd);
182
184
  if (!opened.isFile() || opened.size > 64 * 1024)
183
185
  return;
@@ -191,7 +193,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
191
193
  // Re-check the directory entry against the already-open, verified inode. Cooperating writers are
192
194
  // serialized by the lock; this check also refuses an uncooperative symlink/replacement race.
193
195
  const linked = lstatSync(path);
194
- if (!linked.isFile() || linked.isSymbolicLink() || linked.dev !== opened.dev || linked.ino !== opened.ino)
196
+ if (!linked.isFile() || linked.isSymbolicLink() || !sameOpenedFileIdentity(linked, opened))
195
197
  return;
196
198
  unlinkSync(path);
197
199
  syncDirectory(dir);
@@ -290,7 +290,8 @@ function execute() {
290
290
 
291
291
  const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
292
292
  function sameIdentity(info, candidate) {
293
- return String(info.dev) === candidate.dev && String(info.ino) === candidate.ino;
293
+ return String(info.ino) === candidate.ino
294
+ && (process.platform === "win32" || String(info.dev) === candidate.dev);
294
295
  }
295
296
  function safeReadText(candidate) {
296
297
  const absolute = candidate.path;
@@ -298,7 +299,10 @@ function execute() {
298
299
  try {
299
300
  const before = fs.lstatSync(absolute, { bigint: true });
300
301
  if (!before.isFile() || before.nlink !== 1n || !sameIdentity(before, candidate)) return null;
301
- fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
302
+ const posixFlags = process.platform === "win32"
303
+ ? 0
304
+ : (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0);
305
+ fd = fs.openSync(absolute, fs.constants.O_RDONLY | posixFlags);
302
306
  const info = fs.fstatSync(fd, { bigint: true });
303
307
  if (!info.isFile() || info.nlink !== 1n || !sameIdentity(info, candidate) || info.size > BigInt(cfg.maxFileBytes)) return null;
304
308
  let total = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.124.0",
3
+ "version": "0.124.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"