@nanhara/hara 0.123.1 → 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/dist/config.js CHANGED
@@ -1,9 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { homedir } from "node:os";
3
2
  import { join, dirname, resolve } from "node:path";
4
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
5
4
  import { agentMaxRounds, agentRunTimeoutMs } from "./agent/limits.js";
6
- import { ensurePrivateHaraState } from "./security/private-state.js";
5
+ import { bindPrivateHaraStateFile, ensurePrivateHaraState, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "./security/private-state.js";
7
6
  import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
8
7
  import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
9
8
  import { isHomeWorkspace } from "./context/workspace-scope.js";
@@ -109,15 +108,11 @@ function configRecord(value) {
109
108
  }
110
109
  export function readRawConfig() {
111
110
  ensurePrivateHaraState();
112
- const p = configPath();
113
- if (!existsSync(p))
114
- return {};
115
111
  try {
116
- const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_GLOBAL_CONFIG_BYTES, {
117
- action: "read global config",
118
- protectSensitive: false,
119
- rejectHardLinks: true,
120
- });
112
+ const binding = bindPrivateHaraStateFile(homedir(), [], "config.json");
113
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_GLOBAL_CONFIG_BYTES);
114
+ if (!snapshot)
115
+ return {};
121
116
  return configRecord(JSON.parse(snapshot.text));
122
117
  }
123
118
  catch {
@@ -239,91 +234,33 @@ function readProjectConfig(cwd) {
239
234
  }
240
235
  return {};
241
236
  }
242
- function existingConfigIdentity(path) {
243
- try {
244
- const info = lstatSync(path);
245
- if (info.isSymbolicLink())
246
- throw new Error(`refusing global config write: '${path}' is a symbolic link`);
247
- if (!info.isFile())
248
- throw new Error(`refusing global config write: '${path}' is not a regular file`);
249
- if (info.nlink !== 1)
250
- throw new Error(`refusing global config write: '${path}' is hard-linked`);
251
- return { dev: info.dev, ino: info.ino };
252
- }
253
- catch (error) {
254
- if (error?.code === "ENOENT")
255
- return null;
256
- throw error;
257
- }
258
- }
259
- /** Atomically replace the global 0600 config without opening its current directory entry for write. */
260
- function persistConfig(p, cfg) {
237
+ /** Atomically replace the global 0600 config through the shared private-state CAS boundary. */
238
+ function persistConfig(cfg) {
261
239
  ensurePrivateHaraState();
262
- const parent = dirname(p);
263
- mkdirSync(parent, { recursive: true, mode: 0o700 });
264
- const parentInfo = lstatSync(parent);
265
- const canonicalParent = realpathSync.native(parent);
266
- if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) {
267
- throw new Error(`refusing global config write: '${parent}' is not a canonical directory`);
268
- }
269
- const existing = existingConfigIdentity(p);
270
- const temp = join(parent, `.config-${process.pid}-${randomUUID()}.tmp`);
271
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
272
- let fd;
273
- try {
274
- fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
275
- writeFileSync(fd, JSON.stringify(cfg, null, 2) + "\n", "utf8");
276
- fsyncSync(fd);
277
- const staged = fstatSync(fd);
278
- if (!staged.isFile() || staged.nlink !== 1)
279
- throw new Error("refusing global config write: unsafe staging inode");
280
- closeSync(fd);
281
- fd = undefined;
282
- const parentAfter = lstatSync(parent);
283
- if (!parentAfter.isDirectory() || parentAfter.isSymbolicLink() ||
284
- parentAfter.dev !== parentInfo.dev || parentAfter.ino !== parentInfo.ino ||
285
- realpathSync.native(parent) !== canonicalParent)
286
- throw new Error(`refusing global config write: '${parent}' changed before commit`);
287
- const current = existingConfigIdentity(p);
288
- if ((existing === null) !== (current === null) || (existing && current && (existing.dev !== current.dev || existing.ino !== current.ino))) {
289
- throw new Error(`refusing global config write: '${p}' changed before commit`);
290
- }
291
- // rename replaces the path entry; it never follows an alias planted after the last identity check.
292
- renameSync(temp, p);
293
- const committed = lstatSync(p);
294
- if (!committed.isFile() || committed.isSymbolicLink() || committed.dev !== staged.dev || committed.ino !== staged.ino || committed.nlink !== 1) {
295
- throw new Error(`global config changed during commit: '${p}'`);
296
- }
297
- }
298
- finally {
299
- if (fd !== undefined)
300
- try {
301
- closeSync(fd);
302
- }
303
- catch { /* preserve original error */ }
304
- try {
305
- rmSync(temp, { force: true });
306
- }
307
- catch { /* preserve original error */ }
308
- }
240
+ const binding = bindPrivateHaraStateFile(homedir(), [], "config.json");
241
+ writePrivateStateFileSync(binding, JSON.stringify(cfg, null, 2) + "\n");
242
+ }
243
+ /** Mutate global config without exposing a second direct-write implementation to feature modules. */
244
+ export function updateRawConfig(mutate) {
245
+ const config = readRawConfig();
246
+ const replacement = mutate(config);
247
+ persistConfig(replacement ?? config);
309
248
  }
310
249
  export function writeConfigValue(key, value) {
311
- const p = configPath();
312
- const cfg = readRawConfig();
313
- cfg[key] = value;
314
- persistConfig(p, cfg);
250
+ updateRawConfig((config) => {
251
+ config[key] = value;
252
+ });
315
253
  }
316
254
  /** Record (or clear, with cap=null) a confirmed per-model vision capability in `modelVision`. */
317
255
  export function setModelVisionOverride(model, cap) {
318
- const p = configPath();
319
- const cfg = readRawConfig();
320
- const map = cfg.modelVision && typeof cfg.modelVision === "object" ? cfg.modelVision : {};
321
- if (cap === null)
322
- delete map[model];
323
- else
324
- map[model] = cap;
325
- cfg.modelVision = map;
326
- persistConfig(p, cfg);
256
+ updateRawConfig((config) => {
257
+ const map = config.modelVision && typeof config.modelVision === "object" ? config.modelVision : {};
258
+ if (cap === null)
259
+ delete map[model];
260
+ else
261
+ map[model] = cap;
262
+ config.modelVision = map;
263
+ });
327
264
  }
328
265
  /**
329
266
  * Effective config. Precedence (high→low): env vars > allowed/trusted project `.hara/config.json` >
@@ -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) {
package/dist/desk.js CHANGED
@@ -4,13 +4,15 @@
4
4
  //
5
5
  // Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
6
6
  // `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
7
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
8
7
  import { homedir } from "node:os";
9
- import { join } from "node:path";
10
- const credsPath = () => join(homedir(), ".hara", "desk.json");
8
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "./security/private-state.js";
11
9
  export function loadCreds() {
12
10
  try {
13
- const c = JSON.parse(readFileSync(credsPath(), "utf8"));
11
+ const binding = bindPrivateHaraStateFile(homedir(), [], "desk.json");
12
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
13
+ if (!snapshot)
14
+ return null;
15
+ const c = JSON.parse(snapshot.text);
14
16
  return c.url && c.token ? c : null;
15
17
  }
16
18
  catch {
@@ -18,16 +20,8 @@ export function loadCreds() {
18
20
  }
19
21
  }
20
22
  export function saveCreds(c) {
21
- const dir = join(homedir(), ".hara");
22
- mkdirSync(dir, { recursive: true });
23
- const p = credsPath();
24
- writeFileSync(p, JSON.stringify(c, null, 2));
25
- try {
26
- chmodSync(p, 0o600);
27
- }
28
- catch {
29
- /* best-effort on non-posix */
30
- }
23
+ const binding = bindPrivateHaraStateFile(homedir(), [], "desk.json");
24
+ writePrivateStateFileSync(binding, JSON.stringify(c, null, 2) + "\n");
31
25
  }
32
26
  /** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
33
27
  * the server's error message (so the CLI can surface a real reason, not "request failed"). */
@@ -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,12 +4,35 @@ 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)`);
10
12
  this.name = "BinaryFileError";
11
13
  }
12
14
  }
15
+ export class InvalidUtf8FileError extends Error {
16
+ code = "HARA_INVALID_UTF8";
17
+ constructor(path) {
18
+ super(`${path} is not valid UTF-8; refusing a lossy text operation. ` +
19
+ "Use a binary-aware tool or explicitly transcode the file first");
20
+ this.name = "InvalidUtf8FileError";
21
+ }
22
+ }
23
+ /** Decode a complete text-file snapshot without replacing invalid bytes or dropping a UTF-8 BOM. */
24
+ export function decodeUtf8Strict(bytes, path) {
25
+ if (bytes.includes(0))
26
+ throw new BinaryFileError(path);
27
+ try {
28
+ // ignoreBOM=true means the BOM is emitted as U+FEFF. A later UTF-8 rewrite therefore preserves the
29
+ // original bytes instead of silently stripping the marker while editing otherwise valid text.
30
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
31
+ }
32
+ catch {
33
+ throw new InvalidUtf8FileError(path);
34
+ }
35
+ }
13
36
  export class NonRegularFileError extends Error {
14
37
  code = "HARA_NOT_REGULAR_FILE";
15
38
  constructor(path) {
@@ -44,7 +67,7 @@ export class ProtectedContextFileError extends Error {
44
67
  export class HardLinkedFileError extends Error {
45
68
  code = "HARA_HARD_LINKED_FILE";
46
69
  constructor(path) {
47
- super(`${path} has multiple hard links; its protected identity cannot be established safely`);
70
+ super(`${path} is hard-linked (has multiple hard links); its protected identity cannot be established safely`);
48
71
  this.name = "HardLinkedFileError";
49
72
  }
50
73
  }
@@ -79,10 +102,8 @@ export function verifyOpenedRegularFileSync(path, opened, options = {}) {
79
102
  const currentLink = lstatSync(path);
80
103
  const currentTarget = statSync(canonical);
81
104
  if (currentLink.isSymbolicLink()
82
- || currentLink.dev !== opened.dev
83
- || currentLink.ino !== opened.ino
84
- || currentTarget.dev !== opened.dev
85
- || currentTarget.ino !== opened.ino) {
105
+ || !sameOpenedFileIdentity(currentLink, opened)
106
+ || !sameOpenedFileIdentity(currentTarget, opened)) {
86
107
  throw new Error(`refusing to access ${path}: path changed while opening it`);
87
108
  }
88
109
  return canonical;
@@ -97,8 +118,7 @@ export async function openVerifiedRegularFileNoFollow(path, options = {}) {
97
118
  const before = lstatSync(path);
98
119
  if (before.isSymbolicLink())
99
120
  throw new NonRegularFileError(path);
100
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
101
- 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"));
102
122
  try {
103
123
  const info = await handle.stat();
104
124
  const canonicalPath = verifyOpenedRegularFileSync(path, info, options);
@@ -143,7 +163,7 @@ export async function readVerifiedRegularFileSnapshot(path, maxBytes = MAX_EDIT_
143
163
  || latest.ctimeMs !== info.ctimeMs)
144
164
  throw new Error(`refusing to read ${path}: file changed while reading it`);
145
165
  return {
146
- text: Buffer.concat(chunks, total).toString("utf8"),
166
+ text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
147
167
  dev: info.dev,
148
168
  ino: info.ino,
149
169
  mode: info.mode & 0o777,
@@ -168,8 +188,7 @@ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_RE
168
188
  const before = lstatSync(path);
169
189
  if (before.isSymbolicLink())
170
190
  throw new NonRegularFileError(path);
171
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
172
- 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"));
173
192
  try {
174
193
  const info = fstatSync(fd);
175
194
  verifyOpenedRegularFileSync(path, info, {
@@ -196,7 +215,7 @@ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_RE
196
215
  || latest.ctimeMs !== info.ctimeMs)
197
216
  throw new Error(`refusing to read ${path}: file changed while reading it`);
198
217
  return {
199
- text: bytes.toString("utf8"),
218
+ text: decodeUtf8Strict(bytes, path),
200
219
  dev: info.dev,
201
220
  ino: info.ino,
202
221
  mode: info.mode & 0o777,
@@ -222,11 +241,10 @@ function withVerifiedContextFdSync(path, read) {
222
241
  throw new ProtectedContextFileError(denied);
223
242
  // O_NOFOLLOW is not exposed on every platform. The before/after lstat checks retain fail-closed symlink
224
243
  // behaviour there; on POSIX O_NOFOLLOW makes the critical open itself atomic.
225
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
226
244
  const before = lstatSync(path);
227
245
  if (before.isSymbolicLink())
228
246
  throw new NonRegularFileError(path);
229
- 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"));
230
248
  try {
231
249
  const info = fstatSync(fd);
232
250
  verifyOpenedRegularFileSync(path, info, {
@@ -278,9 +296,7 @@ export function readModelContextFileSync(path, maxBytes) {
278
296
  const bytes = readFdBytesSync(fd, Math.min(limit + 1, size + 1));
279
297
  if (bytes.length > limit)
280
298
  throw new FileReadLimitError(path, limit);
281
- if (bytes.includes(0))
282
- throw new BinaryFileError(path);
283
- return bytes.toString("utf8");
299
+ return decodeUtf8Strict(bytes, path);
284
300
  });
285
301
  }
286
302
  /** Safe bounded-prefix counterpart for @file expansion; it never materializes the remainder of a huge file. */
@@ -361,19 +377,18 @@ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
361
377
  }
362
378
  if (total > limit)
363
379
  throw new FileReadLimitError(path, limit);
364
- return { text: Buffer.concat(chunks, total).toString("utf8"), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
380
+ return { text: decodeUtf8Strict(Buffer.concat(chunks, total), path), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
365
381
  }
366
382
  finally {
367
383
  await handle.close().catch(() => { });
368
384
  }
369
385
  }
370
386
  export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
371
- return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK);
387
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
372
388
  }
373
389
  /** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
374
390
  export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
375
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
376
- 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"));
377
392
  }
378
393
  export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
379
394
  return (await readRegularFileSnapshot(path, maxBytes)).text;
@@ -383,7 +398,7 @@ export function readTextPrefixSync(path, maxChars) {
383
398
  const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
384
399
  const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
385
400
  // O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
386
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
401
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
387
402
  try {
388
403
  const info = fstatSync(fd);
389
404
  if (!info.isFile())
@@ -485,7 +500,8 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
485
500
  const verified = options.protectSensitive
486
501
  ? await openVerifiedRegularFileNoFollow(path, { action: "read", rejectHardLinks: true, protectSensitive: true })
487
502
  : null;
488
- 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"));
489
505
  let stream;
490
506
  try {
491
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
  }