@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/CHANGELOG.md +49 -0
- package/README.md +12 -9
- package/dist/agent/compact.js +98 -18
- package/dist/agent/context-budget.js +180 -0
- package/dist/agent/evolution.js +37 -0
- package/dist/agent/failover.js +3 -4
- package/dist/agent/loop.js +39 -4
- package/dist/config.js +27 -90
- package/dist/cron/store.js +4 -3
- package/dist/desk.js +8 -14
- package/dist/fs-identity.js +11 -0
- package/dist/fs-open-flags.js +14 -0
- package/dist/fs-read.js +38 -22
- package/dist/fs-write.js +4 -3
- package/dist/gateway/outbound-files.js +7 -8
- package/dist/gateway/runtime-state.js +11 -20
- package/dist/gateway/weixin.js +44 -42
- package/dist/index.js +240 -54
- package/dist/org/review-chain.js +2 -2
- package/dist/org-fleet/enroll.js +17 -22
- package/dist/plugins/plugins.js +7 -9
- package/dist/profile/profile.js +29 -56
- package/dist/providers/qwen-oauth.js +6 -19
- package/dist/security/permissions.js +4 -3
- package/dist/security/private-state.js +324 -11
- package/dist/serve/server.js +65 -39
- package/dist/serve/sessions.js +12 -4
- package/dist/session/store.js +4 -0
- package/dist/session/task.js +70 -8
- package/dist/tools/memory.js +18 -3
- package/dist/tools/search.js +6 -2
- package/dist/tui/App.js +43 -17
- package/package.json +1 -1
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 {
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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` >
|
package/dist/cron/store.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
|
83
|
-
||
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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 |
|
|
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
|
-
|
|
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)
|
|
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 |
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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 |
|
|
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
|
|
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
|
|
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
|
|
102
|
-
||
|
|
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
|
|
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
|
|
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 |
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
}
|