@nanhara/hara 0.124.0 → 0.124.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/cron/store.js +4 -3
- package/dist/fs-identity.js +11 -0
- package/dist/fs-open-flags.js +14 -0
- package/dist/fs-read.js +12 -15
- 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/wecom.js +512 -223
- package/dist/org/review-chain.js +2 -2
- package/dist/security/permissions.js +4 -3
- package/dist/security/private-state.js +55 -22
- package/dist/serve/server.js +6 -4
- package/dist/tools/search.js +6 -2
- package/package.json +1 -1
package/dist/org/review-chain.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
&&
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
493
|
-
|| current.
|
|
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)
|
package/dist/serve/server.js
CHANGED
|
@@ -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 |
|
|
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,
|
|
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 |
|
|
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
|
|
196
|
+
if (!linked.isFile() || linked.isSymbolicLink() || !sameOpenedFileIdentity(linked, opened))
|
|
195
197
|
return;
|
|
196
198
|
unlinkSync(path);
|
|
197
199
|
syncDirectory(dir);
|
package/dist/tools/search.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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;
|