@nanhara/hara 0.124.0 → 0.124.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.124.1 — 2026-07-16 — Windows native private-state and file-identity portability
9
+
10
+ - **Windows private state works in both Node and the native Bun executable.** Exclusive staging creation now
11
+ uses the portable `wx`/`CREATE_NEW` contract instead of passing Bun numeric flags that could surface a false
12
+ `ENOENT`. Config, credentials, gateway state, discovery records, and other private atomic writers retain
13
+ create-if-absent publication, hard-link fencing, fsync, and fail-closed alias checks.
14
+ - **Descriptor-to-path identity checks follow the host's real guarantees.** Windows Node may report a
15
+ volume-derived `dev` from `fstat` but `dev=0` from `lstat` for the exact same NTFS file. Hara now correlates
16
+ an already-open descriptor with its already-bounded path using the stable file id and link count, while
17
+ POSIX still requires device, inode, and owner-only mode. Arbitrary path-to-path protected-file detection
18
+ continues to require device + inode, so the portability fix does not weaken hard-link protection.
19
+ - **The release gate now exercises the reported failure on a real Windows runtime.** CI installs the pinned
20
+ Bun 1.3.9 x64-baseline runtime, runs portable file-state contracts, compiles `hara.exe`, and executes the
21
+ hostile-cwd standalone smoke including an isolated `hara doctor`. Bounded failure annotations keep future
22
+ Windows-only regressions diagnosable without exposing credentials. Production dependency audits retry a
23
+ transient registry failure only within a fixed bound and still require a real successful audit result.
24
+ - Windows users on 0.124.0 should upgrade with `npm i -g @nanhara/hara@0.124.1`; Desktop releases that bundle
25
+ a sidecar should pin this patch or newer.
26
+
8
27
  ## 0.124.0 — 2026-07-16 — task-safe interaction, bounded context, and private-state hardening
9
28
 
10
29
  - **Idle conversation no longer hijacks an unfinished task.** Ordinary input starts a new execution while
@@ -7,6 +7,8 @@ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lsta
7
7
  import { cronMatches, parseCron, validTz } from "./schedule.js";
8
8
  import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
9
9
  import { sleepSync } from "../sync-sleep.js";
10
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
11
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
10
12
  /** Hard persistence bounds: outages must apply backpressure instead of growing jobs.json forever. */
11
13
  export const MAX_CRON_PENDING_NOTIFICATIONS = 64;
12
14
  const CRON_RUN_NOTIFICATION_RESERVE = 2;
@@ -43,11 +45,10 @@ function readStoreLockSnapshot(path) {
43
45
  const before = lstatSync(path);
44
46
  let raw = null;
45
47
  if (before.isFile() && before.size <= MAX_STORE_LOCK_BYTES) {
46
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
47
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
48
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
48
49
  try {
49
50
  const opened = fstatSync(fd);
50
- if (opened.isFile() && opened.dev === before.dev && opened.ino === before.ino && opened.size <= MAX_STORE_LOCK_BYTES) {
51
+ if (opened.isFile() && sameOpenedFileIdentity(opened, before) && opened.size <= MAX_STORE_LOCK_BYTES) {
51
52
  const buffer = Buffer.alloc(opened.size);
52
53
  let offset = 0;
53
54
  while (offset < buffer.length) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Correlate an already-open descriptor with a stat of the same bounded path. Node on Windows may report
3
+ * a volume-derived `dev` for fstat and `0` for lstat/stat of that exact NTFS file. Its file id (`ino`) stays
4
+ * stable, and the caller has already fixed the path/parent boundary, so requiring `dev` there rejects valid
5
+ * files without adding an identity fence. POSIX retains the ordinary device + inode comparison.
6
+ *
7
+ * Do not use this to decide whether two arbitrary Windows paths are hard links across unrelated roots.
8
+ */
9
+ export function sameOpenedFileIdentity(left, right, platform = process.platform) {
10
+ return left.ino === right.ino && (platform === "win32" || left.dev === right.dev);
11
+ }
@@ -0,0 +1,14 @@
1
+ import { constants } from "node:fs";
2
+ /**
3
+ * Bun's Windows compatibility layer may expose POSIX-only fs constants even though CreateFileW cannot
4
+ * consume them. Node documents that Windows supports neither O_DIRECTORY, O_NOFOLLOW nor O_NONBLOCK;
5
+ * passing Bun's numeric values can turn an otherwise valid create/open into a misleading ENOENT.
6
+ *
7
+ * Windows callers retain their lstat/fstat/identity checks and O_CREAT|O_EXCL no-replace semantics.
8
+ */
9
+ export function optionalPosixOpenFlag(name, platform = process.platform) {
10
+ if (platform === "win32")
11
+ return 0;
12
+ const value = constants[name];
13
+ return typeof value === "number" ? value : 0;
14
+ }
package/dist/fs-read.js CHANGED
@@ -4,6 +4,8 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpat
4
4
  import { open } from "node:fs/promises";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
  import { sensitiveFileError } from "./security/sensitive-files.js";
7
+ import { optionalPosixOpenFlag } from "./fs-open-flags.js";
8
+ import { sameOpenedFileIdentity } from "./fs-identity.js";
7
9
  export class BinaryFileError extends Error {
8
10
  constructor(path) {
9
11
  super(`${path} appears to be binary (NUL byte detected)`);
@@ -100,10 +102,8 @@ export function verifyOpenedRegularFileSync(path, opened, options = {}) {
100
102
  const currentLink = lstatSync(path);
101
103
  const currentTarget = statSync(canonical);
102
104
  if (currentLink.isSymbolicLink()
103
- || currentLink.dev !== opened.dev
104
- || currentLink.ino !== opened.ino
105
- || currentTarget.dev !== opened.dev
106
- || currentTarget.ino !== opened.ino) {
105
+ || !sameOpenedFileIdentity(currentLink, opened)
106
+ || !sameOpenedFileIdentity(currentTarget, opened)) {
107
107
  throw new Error(`refusing to access ${path}: path changed while opening it`);
108
108
  }
109
109
  return canonical;
@@ -118,8 +118,7 @@ export async function openVerifiedRegularFileNoFollow(path, options = {}) {
118
118
  const before = lstatSync(path);
119
119
  if (before.isSymbolicLink())
120
120
  throw new NonRegularFileError(path);
121
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
122
- const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
121
+ const handle = await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
123
122
  try {
124
123
  const info = await handle.stat();
125
124
  const canonicalPath = verifyOpenedRegularFileSync(path, info, options);
@@ -189,8 +188,7 @@ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_RE
189
188
  const before = lstatSync(path);
190
189
  if (before.isSymbolicLink())
191
190
  throw new NonRegularFileError(path);
192
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
193
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
191
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
194
192
  try {
195
193
  const info = fstatSync(fd);
196
194
  verifyOpenedRegularFileSync(path, info, {
@@ -243,11 +241,10 @@ function withVerifiedContextFdSync(path, read) {
243
241
  throw new ProtectedContextFileError(denied);
244
242
  // O_NOFOLLOW is not exposed on every platform. The before/after lstat checks retain fail-closed symlink
245
243
  // behaviour there; on POSIX O_NOFOLLOW makes the critical open itself atomic.
246
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
247
244
  const before = lstatSync(path);
248
245
  if (before.isSymbolicLink())
249
246
  throw new NonRegularFileError(path);
250
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
247
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
251
248
  try {
252
249
  const info = fstatSync(fd);
253
250
  verifyOpenedRegularFileSync(path, info, {
@@ -387,12 +384,11 @@ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
387
384
  }
388
385
  }
389
386
  export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
390
- return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK);
387
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
391
388
  }
392
389
  /** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
393
390
  export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
394
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
395
- return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
391
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
396
392
  }
397
393
  export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
398
394
  return (await readRegularFileSnapshot(path, maxBytes)).text;
@@ -402,7 +398,7 @@ export function readTextPrefixSync(path, maxChars) {
402
398
  const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
403
399
  const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
404
400
  // O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
405
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
401
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
406
402
  try {
407
403
  const info = fstatSync(fd);
408
404
  if (!info.isFile())
@@ -504,7 +500,8 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
504
500
  const verified = options.protectSensitive
505
501
  ? await openVerifiedRegularFileNoFollow(path, { action: "read", rejectHardLinks: true, protectSensitive: true })
506
502
  : null;
507
- const handle = verified?.handle ?? await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
503
+ const handle = verified?.handle
504
+ ?? await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
508
505
  let stream;
509
506
  try {
510
507
  const info = verified?.info ?? await handle.stat();
package/dist/fs-write.js CHANGED
@@ -5,6 +5,8 @@ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { constants, linkSync, lstatSync, readlinkSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
6
6
  import { lstat, mkdir, open, realpath, rmdir, stat } from "node:fs/promises";
7
7
  import { NonRegularFileError, readRegularFileSnapshotNoFollow } from "./fs-read.js";
8
+ import { optionalPosixOpenFlag } from "./fs-open-flags.js";
9
+ import { sameOpenedFileIdentity } from "./fs-identity.js";
8
10
  import { canonicalizeProspectivePath, lexicalSensitiveFileReason, sensitiveFileError, } from "./security/sensitive-files.js";
9
11
  export class FileChangedError extends Error {
10
12
  code = "HARA_FILE_CHANGED";
@@ -296,7 +298,7 @@ async function syncDirectory(path) {
296
298
  try {
297
299
  // The directory name can be exchanged after rename. O_NONBLOCK plus fstat on this exact descriptor
298
300
  // keeps best-effort durability from hanging forever on a replacement FIFO/device.
299
- const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
301
+ const handle = await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
300
302
  try {
301
303
  if (!(await handle.stat()).isDirectory())
302
304
  return;
@@ -508,8 +510,7 @@ export async function atomicWriteText(path, content, options = {}) {
508
510
  verifyCommitParent();
509
511
  const current = lstatSync(temp);
510
512
  if (current.isFile()
511
- && current.dev === writtenIdentity.dev
512
- && current.ino === writtenIdentity.ino)
513
+ && sameOpenedFileIdentity(current, writtenIdentity))
513
514
  unlinkSync(temp);
514
515
  }
515
516
  catch {
@@ -6,6 +6,8 @@ import { chmodSync, constants, lstatSync, mkdirSync, readdirSync, realpathSync,
6
6
  import { open } from "node:fs/promises";
7
7
  import { basename, dirname, extname, join, resolve } from "node:path";
8
8
  import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync } from "../fs-read.js";
9
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
9
11
  export const OUTBOUND_FILE_MAX_BYTES = 20 * 1024 * 1024;
10
12
  export const OUTBOUND_BATCH_MAX_BYTES = 20 * 1024 * 1024;
11
13
  export const OUTBOUND_BATCH_MAX_FILES = 4;
@@ -90,17 +92,15 @@ async function appendOutbox(outbox, snapshot, signal) {
90
92
  if (error?.code !== "ENOENT")
91
93
  throw error;
92
94
  }
93
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
94
- const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | noFollow, 0o600);
95
+ const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | optionalPosixOpenFlag("O_NOFOLLOW"), 0o600);
95
96
  try {
96
97
  const info = await handle.stat();
97
98
  const current = lstatSync(path);
98
99
  if (!info.isFile()
99
100
  || info.nlink > 1
100
101
  || current.isSymbolicLink()
101
- || current.dev !== info.dev
102
- || current.ino !== info.ino
103
- || (before && (before.dev !== info.dev || before.ino !== info.ino))
102
+ || !sameOpenedFileIdentity(current, info)
103
+ || (before && !sameOpenedFileIdentity(before, info))
104
104
  || !ownedByProcess(path))
105
105
  throw new Error(`unsafe gateway outbox: ${outbox}`);
106
106
  await handle.chmod(0o600);
@@ -251,7 +251,7 @@ async function readOutbox(outbox) {
251
251
  await verified?.handle.close().catch(() => { });
252
252
  try {
253
253
  const current = lstatSync(resolve(outbox));
254
- if (verified && !current.isSymbolicLink() && current.dev === verified.info.dev && current.ino === verified.info.ino) {
254
+ if (verified && !current.isSymbolicLink() && sameOpenedFileIdentity(current, verified.info)) {
255
255
  unlinkSync(resolve(outbox));
256
256
  }
257
257
  }
@@ -380,8 +380,7 @@ export function cleanupOutboundSnapshot(path) {
380
380
  && entry.isFile()
381
381
  && !entry.isSymbolicLink()
382
382
  && entry.nlink === 1
383
- && entry.dev === expected.dev
384
- && entry.ino === expected.ino
383
+ && sameOpenedFileIdentity(entry, expected)
385
384
  && ownedByProcess(candidate))
386
385
  unlinkSync(candidate);
387
386
  if (readdirSync(dir).length === 0)
@@ -13,6 +13,8 @@ import { atomicWriteText, bindHaraPrivateStateWritePath, FileChangedError } from
13
13
  import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, } from "../security/private-state.js";
14
14
  import { sleepSync } from "../sync-sleep.js";
15
15
  import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
16
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
17
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
16
18
  const PLATFORM = /^[a-z0-9][a-z0-9_-]{0,63}$/;
17
19
  const LOCK_BYTES = 4 * 1024;
18
20
  const LOCK_WAIT_MS = 5_000;
@@ -100,13 +102,11 @@ function leaseStagingPath(path, record) {
100
102
  return `${path}.${record.pid}.${record.token}.pending`;
101
103
  }
102
104
  function readLease(path, platform) {
103
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
104
- const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
105
105
  let fd;
106
106
  try {
107
107
  // O_NONBLOCK is inert for regular files and prevents a hostile FIFO/device at the lock path from hanging
108
108
  // gateway startup before fstat can reject it.
109
- fd = openSync(path, constants.O_RDONLY | noFollow | nonBlock);
109
+ fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NOFOLLOW") | optionalPosixOpenFlag("O_NONBLOCK"));
110
110
  }
111
111
  catch (error) {
112
112
  if (errorCode(error) === "ENOENT")
@@ -175,8 +175,7 @@ function readLease(path, platform) {
175
175
  if (!staging.isFile()
176
176
  || staging.isSymbolicLink()
177
177
  || staging.nlink !== 2
178
- || staging.dev !== info.dev
179
- || staging.ino !== info.ino)
178
+ || !sameOpenedFileIdentity(staging, info))
180
179
  throw new Error(`refusing malformed gateway instance lock: ${path}`);
181
180
  return { record, dev: info.dev, ino: info.ino, stagingPath };
182
181
  }
@@ -187,8 +186,7 @@ function readLease(path, platform) {
187
186
  function sameLease(left, right) {
188
187
  return Boolean(left
189
188
  && right
190
- && left.dev === right.dev
191
- && left.ino === right.ino
189
+ && sameOpenedFileIdentity(left, right)
192
190
  && left.record.pid === right.record.pid
193
191
  && left.record.token === right.record.token);
194
192
  }
@@ -198,8 +196,7 @@ function unlinkLease(path, expected) {
198
196
  || current.isSymbolicLink()
199
197
  || current.nlink < 1
200
198
  || current.nlink > 2
201
- || current.dev !== expected.dev
202
- || current.ino !== expected.ino)
199
+ || !sameOpenedFileIdentity(current, expected))
203
200
  throw new Error(`gateway instance lock changed before removal: ${path}`);
204
201
  if (current.nlink === 2) {
205
202
  if (!expected.stagingPath)
@@ -208,30 +205,26 @@ function unlinkLease(path, expected) {
208
205
  if (!staging.isFile()
209
206
  || staging.isSymbolicLink()
210
207
  || staging.nlink !== 2
211
- || staging.dev !== expected.dev
212
- || staging.ino !== expected.ino)
208
+ || !sameOpenedFileIdentity(staging, expected))
213
209
  throw new Error(`gateway instance lock changed before removal: ${path}`);
214
210
  unlinkSync(expected.stagingPath);
215
211
  current = lstatSync(path);
216
212
  if (!current.isFile()
217
213
  || current.isSymbolicLink()
218
214
  || current.nlink !== 1
219
- || current.dev !== expected.dev
220
- || current.ino !== expected.ino)
215
+ || !sameOpenedFileIdentity(current, expected))
221
216
  throw new Error(`gateway instance lock changed before removal: ${path}`);
222
217
  }
223
218
  unlinkSync(path);
224
219
  }
225
220
  function writeLease(path, record) {
226
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
227
- const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
228
221
  const stagingPath = leaseStagingPath(path, record);
229
222
  let fd;
230
223
  let published = false;
231
224
  try {
232
225
  // Never expose an empty/partial owner record at the canonical path. The private staging inode is fully
233
226
  // written + fsynced first; link(2) then publishes it atomically and refuses to replace an existing owner.
234
- fd = openSync(stagingPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow | nonBlock, 0o600);
227
+ fd = openSync(stagingPath, "wx", 0o600);
235
228
  writeFileSync(fd, JSON.stringify(record), "utf8");
236
229
  fsyncSync(fd);
237
230
  closeSync(fd);
@@ -853,8 +846,7 @@ export class GatewayFlowRunStore {
853
846
  if (!current.isFile()
854
847
  || current.isSymbolicLink()
855
848
  || current.nlink !== 1
856
- || current.dev !== snapshot.dev
857
- || current.ino !== snapshot.ino)
849
+ || !sameOpenedFileIdentity(current, snapshot))
858
850
  throw new Error(`gateway flow run changed before removal: ${path}`);
859
851
  unlinkSync(path);
860
852
  }
@@ -1172,8 +1164,7 @@ export class GatewayRunOutcomeStore {
1172
1164
  if (!current.isFile()
1173
1165
  || current.isSymbolicLink()
1174
1166
  || current.nlink !== 1
1175
- || current.dev !== snapshot.dev
1176
- || current.ino !== snapshot.ino)
1167
+ || !sameOpenedFileIdentity(current, snapshot))
1177
1168
  throw new Error(`gateway run outcome changed before removal: ${path}`);
1178
1169
  unlinkSync(path);
1179
1170
  }
@@ -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.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"