@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/plugins/plugins.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
|
|
3
3
|
// index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
|
|
4
4
|
// .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
|
|
5
|
-
import { readFileSync,
|
|
5
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
|
|
6
6
|
import { join, resolve, isAbsolute } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
|
-
import { readRawConfig } from "../config.js";
|
|
9
|
+
import { readRawConfig, updateRawConfig } from "../config.js";
|
|
10
10
|
export function pluginsDir() {
|
|
11
11
|
return join(homedir(), ".hara", "plugins");
|
|
12
12
|
}
|
|
@@ -193,11 +193,9 @@ export function panelsForProject(cwd) {
|
|
|
193
193
|
}
|
|
194
194
|
/** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
|
|
195
195
|
export function setPluginEnabled(name, on) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
202
|
-
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
196
|
+
updateRawConfig((config) => {
|
|
197
|
+
const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
|
|
198
|
+
plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
|
|
199
|
+
config.plugins = plugins;
|
|
200
|
+
});
|
|
203
201
|
}
|
package/dist/profile/profile.js
CHANGED
|
@@ -32,29 +32,22 @@
|
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { spawnSync } from "node:child_process";
|
|
34
34
|
import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
35
|
-
import {
|
|
35
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
36
|
+
import { readRawConfig, updateRawConfig } from "../config.js";
|
|
36
37
|
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
37
38
|
import { atomicWriteText, bindProfilePinWritePath, discardClaimedPath, verifyAtomicWriteBoundary, } from "../fs-write.js";
|
|
38
39
|
import { projectRepositoryTrustedAtStartup } from "../security/project-trust.js";
|
|
40
|
+
import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
39
41
|
const PERSONAL_ID = "personal";
|
|
40
42
|
const DEFAULT_ORG_ID = "default-org";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
function profilesPath() {
|
|
45
|
-
return join(haraDir(), "profiles.json");
|
|
46
|
-
}
|
|
47
|
-
function configPath() {
|
|
48
|
-
return join(haraDir(), "config.json");
|
|
49
|
-
}
|
|
50
|
-
function orgPath() {
|
|
51
|
-
return join(haraDir(), "org.json");
|
|
52
|
-
}
|
|
53
|
-
function readJSON(p) {
|
|
54
|
-
if (!existsSync(p))
|
|
55
|
-
return null;
|
|
43
|
+
const MAX_PROFILE_STATE_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
function readPrivateJSON(filename) {
|
|
56
45
|
try {
|
|
57
|
-
|
|
46
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], filename);
|
|
47
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_PROFILE_STATE_BYTES);
|
|
48
|
+
if (!snapshot)
|
|
49
|
+
return null;
|
|
50
|
+
return { binding, snapshot, value: JSON.parse(snapshot.text) };
|
|
58
51
|
}
|
|
59
52
|
catch {
|
|
60
53
|
return null;
|
|
@@ -62,20 +55,13 @@ function readJSON(p) {
|
|
|
62
55
|
}
|
|
63
56
|
/** Write the profiles file 0600 (it can hold device tokens / api keys). */
|
|
64
57
|
function persistProfilesFile(f) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
writeFileSync(p, JSON.stringify(f, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
68
|
-
try {
|
|
69
|
-
chmodSync(p, 0o600);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
/* best-effort */
|
|
73
|
-
}
|
|
58
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "profiles.json");
|
|
59
|
+
writePrivateStateFileSync(binding, JSON.stringify(f, null, 2) + "\n");
|
|
74
60
|
}
|
|
75
61
|
/** Synthesize the "personal" profile view from the legacy config.json. The config.json itself
|
|
76
62
|
* stays the *storage* — this just presents it as a Profile object. */
|
|
77
63
|
function readPersonalFromConfig() {
|
|
78
|
-
const cfg =
|
|
64
|
+
const cfg = readRawConfig();
|
|
79
65
|
// A legacy user that ran `hara enroll` had their provider written as "hara-gateway" in config.json.
|
|
80
66
|
// After migration that case is handled separately (default-org profile), so when synthesizing the
|
|
81
67
|
// personal profile we coerce a stray "hara-gateway" provider to anthropic (the BYOK default) — the
|
|
@@ -97,8 +83,7 @@ function readPersonalFromConfig() {
|
|
|
97
83
|
};
|
|
98
84
|
}
|
|
99
85
|
/** Synthesize a `default-org` profile from the legacy org.json (Enrollment). */
|
|
100
|
-
function readDefaultOrgFromOrgJson() {
|
|
101
|
-
const e = readJSON(orgPath());
|
|
86
|
+
function readDefaultOrgFromOrgJson(e) {
|
|
102
87
|
if (!e || !e.gatewayUrl || !e.deviceToken)
|
|
103
88
|
return null;
|
|
104
89
|
const defaultModel = e.model || "";
|
|
@@ -117,11 +102,12 @@ function readDefaultOrgFromOrgJson() {
|
|
|
117
102
|
}
|
|
118
103
|
/** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
|
|
119
104
|
function maybeMigrate() {
|
|
120
|
-
const existing =
|
|
105
|
+
const existing = readPrivateJSON("profiles.json")?.value;
|
|
121
106
|
if (existing && Array.isArray(existing.profiles) && existing.profiles.length > 0)
|
|
122
107
|
return existing;
|
|
123
108
|
const personal = readPersonalFromConfig();
|
|
124
|
-
const
|
|
109
|
+
const legacyOrg = readPrivateJSON("org.json");
|
|
110
|
+
const org = readDefaultOrgFromOrgJson(legacyOrg?.value ?? null);
|
|
125
111
|
const profiles = [personal];
|
|
126
112
|
let active = PERSONAL_ID;
|
|
127
113
|
if (org) {
|
|
@@ -130,10 +116,13 @@ function maybeMigrate() {
|
|
|
130
116
|
}
|
|
131
117
|
const f = { active, profiles };
|
|
132
118
|
persistProfilesFile(f);
|
|
133
|
-
// Park
|
|
134
|
-
|
|
119
|
+
// Park the exact verified legacy bytes without ever following/replacing an alias. If archival races or
|
|
120
|
+
// fails, leave org.json untouched; profiles.json already makes the migration idempotent.
|
|
121
|
+
if (org && legacyOrg) {
|
|
135
122
|
try {
|
|
136
|
-
|
|
123
|
+
const archive = bindPrivateHaraStateFile(homedir(), [], "org.json.legacy");
|
|
124
|
+
writePrivateStateFileSync(archive, legacyOrg.snapshot.text);
|
|
125
|
+
removePrivateStateFile(legacyOrg.binding.path, legacyOrg.snapshot, legacyOrg.binding.directory);
|
|
137
126
|
}
|
|
138
127
|
catch {
|
|
139
128
|
/* best-effort */
|
|
@@ -531,31 +520,15 @@ export function routeHost(p) {
|
|
|
531
520
|
// config.ts without circular imports. Implemented inline to avoid pulling config.ts.
|
|
532
521
|
// ────────────────────────────────────────────────────────────────────────────────
|
|
533
522
|
function setModelOnPersonal(model) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
538
|
-
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
539
|
-
try {
|
|
540
|
-
chmodSync(p, 0o600);
|
|
541
|
-
}
|
|
542
|
-
catch {
|
|
543
|
-
/* best-effort */
|
|
544
|
-
}
|
|
523
|
+
updateRawConfig((config) => {
|
|
524
|
+
config.model = model;
|
|
525
|
+
});
|
|
545
526
|
return { ok: true };
|
|
546
527
|
}
|
|
547
528
|
function clearModelOnPersonal() {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
552
|
-
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
553
|
-
try {
|
|
554
|
-
chmodSync(p, 0o600);
|
|
555
|
-
}
|
|
556
|
-
catch {
|
|
557
|
-
/* best-effort */
|
|
558
|
-
}
|
|
529
|
+
updateRawConfig((config) => {
|
|
530
|
+
delete config.model;
|
|
531
|
+
});
|
|
559
532
|
return { ok: true };
|
|
560
533
|
}
|
|
561
534
|
export { PERSONAL_ID, DEFAULT_ORG_ID };
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// Token (access/refresh/resource_url) is stored in ~/.hara/qwen-oauth.json and auto-refreshed.
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import {
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
|
|
5
|
+
import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
7
6
|
const BASE = "https://chat.qwen.ai";
|
|
8
7
|
const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
|
|
9
8
|
const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
|
|
@@ -11,31 +10,19 @@ const CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
|
|
|
11
10
|
const SCOPE = "openid profile email model.completion";
|
|
12
11
|
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
13
12
|
const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1";
|
|
14
|
-
function tokenPath() {
|
|
15
|
-
return join(homedir(), ".hara", "qwen-oauth.json");
|
|
16
|
-
}
|
|
17
13
|
export function loadQwenToken() {
|
|
18
|
-
const p = tokenPath();
|
|
19
|
-
if (!existsSync(p))
|
|
20
|
-
return null;
|
|
21
14
|
try {
|
|
22
|
-
|
|
15
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "qwen-oauth.json");
|
|
16
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
|
|
17
|
+
return snapshot ? JSON.parse(snapshot.text) : null;
|
|
23
18
|
}
|
|
24
19
|
catch {
|
|
25
20
|
return null;
|
|
26
21
|
}
|
|
27
22
|
}
|
|
28
23
|
function saveQwenToken(t) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
// 0600 — the file holds long-lived access + refresh tokens; don't leave it world-readable on shared boxes.
|
|
32
|
-
writeFileSync(p, JSON.stringify(t, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
33
|
-
try {
|
|
34
|
-
chmodSync(p, 0o600); // tighten an existing file that predated the mode
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
/* best-effort */
|
|
38
|
-
}
|
|
24
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "qwen-oauth.json");
|
|
25
|
+
writePrivateStateFileSync(binding, JSON.stringify(t, null, 2) + "\n");
|
|
39
26
|
}
|
|
40
27
|
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
41
28
|
function pkce() {
|
|
@@ -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 {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Owner-only migration for Hara's local control plane. New writers should still create private files
|
|
2
2
|
// directly, but this repairs installations created by older releases and makes ~/.hara non-traversable by
|
|
3
3
|
// other local users before credentials/session state are read.
|
|
4
|
-
import { closeSync, chmodSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, realpathSync, unlinkSync, } from "node:fs";
|
|
4
|
+
import { closeSync, chmodSync, constants, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
|
-
import { basename, join, resolve
|
|
7
|
-
import { FileReadLimitError, MAX_EDIT_READ_BYTES, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
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";
|
|
8
11
|
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
|
|
9
12
|
const tightenedHomes = new Set();
|
|
10
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
@@ -23,7 +26,12 @@ function chmodPrivate(path, mode) {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
function checkedPrivateComponent(component) {
|
|
26
|
-
if (!component
|
|
29
|
+
if (!component
|
|
30
|
+
|| component === "."
|
|
31
|
+
|| component === ".."
|
|
32
|
+
|| basename(component) !== component
|
|
33
|
+
|| /[\\/]/.test(component)
|
|
34
|
+
|| component.includes("\0")) {
|
|
27
35
|
throw new Error(`invalid private Hara state path component '${component}'`);
|
|
28
36
|
}
|
|
29
37
|
return component;
|
|
@@ -58,9 +66,10 @@ function verifyAndTightenPrivateDirectory(path) {
|
|
|
58
66
|
// no POSIX ownership mode to repair
|
|
59
67
|
}
|
|
60
68
|
else {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
const fd = openSync(path, constants.O_RDONLY
|
|
70
|
+
| optionalPosixOpenFlag("O_NONBLOCK")
|
|
71
|
+
| optionalPosixOpenFlag("O_NOFOLLOW")
|
|
72
|
+
| optionalPosixOpenFlag("O_DIRECTORY"));
|
|
64
73
|
try {
|
|
65
74
|
const opened = fstatSync(fd);
|
|
66
75
|
if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
|
|
@@ -128,6 +137,311 @@ export function ensurePrivateStateSubdirectory(base, components, tightenExisting
|
|
|
128
137
|
}
|
|
129
138
|
return parent;
|
|
130
139
|
}
|
|
140
|
+
/** Bind one immediate file below a symlink-free, owner-only Hara state directory. */
|
|
141
|
+
export function bindPrivateHaraStateFile(home, subdirectories, filename) {
|
|
142
|
+
const directory = ensurePrivateStateSubdirectory(home, [".hara", ...subdirectories]);
|
|
143
|
+
const name = checkedPrivateComponent(filename);
|
|
144
|
+
const path = join(directory.path, name);
|
|
145
|
+
if (dirname(path) !== directory.path)
|
|
146
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
147
|
+
verifyPrivateDirectory(directory);
|
|
148
|
+
return { directory, path };
|
|
149
|
+
}
|
|
150
|
+
function privateReadLimit(maxBytes) {
|
|
151
|
+
const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
|
|
152
|
+
return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
|
|
153
|
+
}
|
|
154
|
+
function readFdBytes(fd, count) {
|
|
155
|
+
const out = Buffer.allocUnsafe(count);
|
|
156
|
+
let offset = 0;
|
|
157
|
+
while (offset < count) {
|
|
158
|
+
const read = readSync(fd, out, offset, count - offset, offset);
|
|
159
|
+
if (!read)
|
|
160
|
+
break;
|
|
161
|
+
offset += read;
|
|
162
|
+
}
|
|
163
|
+
return out.subarray(0, offset);
|
|
164
|
+
}
|
|
165
|
+
/** Synchronous private-state reader for startup/auth APIs that cannot make their public contract async. */
|
|
166
|
+
export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
167
|
+
const limit = privateReadLimit(maxBytes);
|
|
168
|
+
let before;
|
|
169
|
+
try {
|
|
170
|
+
before = lstatSync(path);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error?.code === "ENOENT")
|
|
174
|
+
return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
if (before.isSymbolicLink())
|
|
178
|
+
throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
|
|
179
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
180
|
+
try {
|
|
181
|
+
let info = fstatSync(fd);
|
|
182
|
+
verifyOpenedRegularFileSync(path, info, {
|
|
183
|
+
action: "read private Hara state",
|
|
184
|
+
rejectHardLinks: true,
|
|
185
|
+
protectSensitive: false,
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
fchmodSync(fd, 0o600);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (process.platform !== "win32")
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
// chmod may update ctime; capture the authoritative baseline afterwards.
|
|
195
|
+
info = fstatSync(fd);
|
|
196
|
+
if (info.size > limit)
|
|
197
|
+
throw new FileReadLimitError(path, limit);
|
|
198
|
+
const bytes = readFdBytes(fd, Math.min(limit + 1, info.size + 1));
|
|
199
|
+
if (bytes.length > limit)
|
|
200
|
+
throw new FileReadLimitError(path, limit);
|
|
201
|
+
const latest = fstatSync(fd);
|
|
202
|
+
verifyOpenedRegularFileSync(path, latest, {
|
|
203
|
+
action: "read private Hara state",
|
|
204
|
+
rejectHardLinks: true,
|
|
205
|
+
protectSensitive: false,
|
|
206
|
+
});
|
|
207
|
+
if (latest.dev !== info.dev
|
|
208
|
+
|| latest.ino !== info.ino
|
|
209
|
+
|| latest.size !== info.size
|
|
210
|
+
|| latest.mtimeMs !== info.mtimeMs
|
|
211
|
+
|| latest.ctimeMs !== info.ctimeMs)
|
|
212
|
+
throw new Error(`private Hara state file changed while reading: '${path}'`);
|
|
213
|
+
return {
|
|
214
|
+
text: decodeUtf8Strict(bytes, path),
|
|
215
|
+
dev: latest.dev,
|
|
216
|
+
ino: latest.ino,
|
|
217
|
+
mode: latest.mode & 0o777,
|
|
218
|
+
nlink: latest.nlink,
|
|
219
|
+
size: latest.size,
|
|
220
|
+
mtimeMs: latest.mtimeMs,
|
|
221
|
+
ctimeMs: latest.ctimeMs,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
closeSync(fd);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function samePrivateFile(path, expected) {
|
|
229
|
+
const info = lstatSync(path);
|
|
230
|
+
return (info.isFile()
|
|
231
|
+
&& !info.isSymbolicLink()
|
|
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
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function restorePrivateClaim(claimed, target, expected) {
|
|
267
|
+
if (!samePrivateFile(claimed, expected)) {
|
|
268
|
+
throw new Error(`private Hara state claim changed; original entry is preserved at '${claimed}'`);
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
linkSync(claimed, target);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error?.code === "EEXIST") {
|
|
275
|
+
throw new Error(`another entry appeared at '${target}'; original entry is preserved at '${claimed}'`);
|
|
276
|
+
}
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
const linked = { ...expected, nlink: expected.nlink + 1 };
|
|
280
|
+
if (!samePrivateFile(claimed, linked) || !samePrivateFile(target, linked)) {
|
|
281
|
+
throw new Error(`private Hara state restore changed; original entry is preserved at '${claimed}'`);
|
|
282
|
+
}
|
|
283
|
+
unlinkSync(claimed);
|
|
284
|
+
}
|
|
285
|
+
function syncPrivateDirectory(path) {
|
|
286
|
+
try {
|
|
287
|
+
const fd = openSync(path, constants.O_RDONLY
|
|
288
|
+
| optionalPosixOpenFlag("O_NONBLOCK")
|
|
289
|
+
| optionalPosixOpenFlag("O_NOFOLLOW")
|
|
290
|
+
| optionalPosixOpenFlag("O_DIRECTORY"));
|
|
291
|
+
try {
|
|
292
|
+
if (fstatSync(fd).isDirectory())
|
|
293
|
+
fsyncSync(fd);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
closeSync(fd);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* Directory fsync is not portable; the staged file itself is always fsynced. */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
|
|
305
|
+
* are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
|
|
306
|
+
*/
|
|
307
|
+
export function writePrivateStateFileSync(binding, text) {
|
|
308
|
+
const { directory, path } = binding;
|
|
309
|
+
verifyPrivateDirectory(directory);
|
|
310
|
+
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
311
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
312
|
+
}
|
|
313
|
+
const existing = readPrivateStateFileSnapshotSync(path);
|
|
314
|
+
verifyPrivateDirectory(directory);
|
|
315
|
+
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
316
|
+
let fd;
|
|
317
|
+
let staged;
|
|
318
|
+
try {
|
|
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);
|
|
322
|
+
writeFileSync(fd, text, "utf8");
|
|
323
|
+
try {
|
|
324
|
+
fchmodSync(fd, 0o600);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (process.platform !== "win32")
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
fsyncSync(fd);
|
|
331
|
+
const stagedInfo = fstatSync(fd);
|
|
332
|
+
if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
|
|
333
|
+
throw new Error("unsafe private Hara state staging inode");
|
|
334
|
+
staged = {
|
|
335
|
+
dev: stagedInfo.dev,
|
|
336
|
+
ino: stagedInfo.ino,
|
|
337
|
+
mode: stagedInfo.mode & 0o777,
|
|
338
|
+
nlink: stagedInfo.nlink,
|
|
339
|
+
};
|
|
340
|
+
closeSync(fd);
|
|
341
|
+
fd = undefined;
|
|
342
|
+
if (!existing) {
|
|
343
|
+
try {
|
|
344
|
+
verifyPrivateDirectory(directory);
|
|
345
|
+
linkSync(temp, path);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error?.code === "EEXIST")
|
|
349
|
+
throw new Error(`private Hara state file changed before create: '${path}'`);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const claimed = join(directory.path, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
|
|
355
|
+
try {
|
|
356
|
+
verifyPrivateDirectory(directory);
|
|
357
|
+
renameSync(path, claimed);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (error?.code === "ENOENT")
|
|
361
|
+
throw new Error(`private Hara state file changed before replace: '${path}'`);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
let verifiedClaim = false;
|
|
365
|
+
try {
|
|
366
|
+
const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
|
|
367
|
+
verifiedClaim = Boolean(claimedSnapshot
|
|
368
|
+
&& sameOpenedFileIdentity(claimedSnapshot, existing)
|
|
369
|
+
&& claimedSnapshot.mode === existing.mode
|
|
370
|
+
&& claimedSnapshot.nlink === existing.nlink
|
|
371
|
+
&& claimedSnapshot.text === existing.text);
|
|
372
|
+
if (!verifiedClaim)
|
|
373
|
+
throw new Error(`private Hara state file changed before replace: '${path}'`);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
try {
|
|
377
|
+
restorePrivateClaim(claimed, path, existing);
|
|
378
|
+
}
|
|
379
|
+
catch (restoreError) {
|
|
380
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
verifyPrivateDirectory(directory);
|
|
386
|
+
linkSync(temp, path);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
let recovery = "";
|
|
390
|
+
try {
|
|
391
|
+
restorePrivateClaim(claimed, path, existing);
|
|
392
|
+
}
|
|
393
|
+
catch (restoreError) {
|
|
394
|
+
recovery = `; original entry is retained: ${restoreError?.message ?? String(restoreError)}`;
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
|
|
397
|
+
}
|
|
398
|
+
if (verifiedClaim) {
|
|
399
|
+
try {
|
|
400
|
+
if (samePrivateFile(claimed, existing))
|
|
401
|
+
unlinkSync(claimed);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// The new entry is already committed. Retain a changed/unremovable unpredictable claim instead of
|
|
405
|
+
// risking deletion of a concurrently supplied path; it contains only the previous private state.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
|
|
410
|
+
if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
|
|
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)}`);
|
|
415
|
+
}
|
|
416
|
+
unlinkSync(temp);
|
|
417
|
+
const committed = lstatSync(path);
|
|
418
|
+
if (!staged
|
|
419
|
+
|| !committed.isFile()
|
|
420
|
+
|| committed.isSymbolicLink()
|
|
421
|
+
|| !sameOpenedFileIdentity(committed, staged)
|
|
422
|
+
|| committed.nlink !== 1
|
|
423
|
+
|| (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
|
|
424
|
+
throw new Error(`private Hara state file changed during commit: '${path}'`);
|
|
425
|
+
verifyPrivateDirectory(directory);
|
|
426
|
+
syncPrivateDirectory(directory.path);
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
if (fd !== undefined)
|
|
430
|
+
try {
|
|
431
|
+
closeSync(fd);
|
|
432
|
+
}
|
|
433
|
+
catch { /* preserve original error */ }
|
|
434
|
+
if (staged) {
|
|
435
|
+
try {
|
|
436
|
+
if (samePrivateFile(temp, staged))
|
|
437
|
+
unlinkSync(temp);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
131
445
|
/** Open/read one internal state file without following aliases, reject hard links, and repair mode by fd. */
|
|
132
446
|
export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
133
447
|
const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
|
|
@@ -185,7 +499,7 @@ export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_REA
|
|
|
185
499
|
|| after.ctimeMs !== before.ctimeMs)
|
|
186
500
|
throw new Error(`private Hara state file changed while reading: '${path}'`);
|
|
187
501
|
return {
|
|
188
|
-
text: Buffer.concat(chunks, total)
|
|
502
|
+
text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
|
|
189
503
|
dev: after.dev,
|
|
190
504
|
ino: after.ino,
|
|
191
505
|
mode: after.mode & 0o777,
|
|
@@ -209,9 +523,8 @@ export function removePrivateStateFile(path, expected, directory) {
|
|
|
209
523
|
if (!current.isFile()
|
|
210
524
|
|| current.isSymbolicLink()
|
|
211
525
|
|| current.nlink !== 1
|
|
212
|
-
|| current
|
|
213
|
-
|| current.
|
|
214
|
-
|| (current.mode & 0o777) !== expected.mode
|
|
526
|
+
|| !sameOpenedFileIdentity(current, expected)
|
|
527
|
+
|| (process.platform !== "win32" && (current.mode & 0o777) !== expected.mode)
|
|
215
528
|
|| current.size !== expected.size
|
|
216
529
|
|| current.mtimeMs !== expected.mtimeMs
|
|
217
530
|
|| current.ctimeMs !== expected.ctimeMs)
|