@nanhara/hara 0.123.1 → 0.124.0
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 +30 -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/desk.js +8 -14
- package/dist/fs-read.js +26 -7
- package/dist/gateway/weixin.js +44 -42
- package/dist/index.js +240 -54
- 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/private-state.js +285 -5
- package/dist/serve/server.js +59 -35
- 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/tui/App.js +43 -17
- package/package.json +1 -1
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() {
|
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
8
9
|
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
|
|
9
10
|
const tightenedHomes = new Set();
|
|
10
11
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
@@ -23,7 +24,12 @@ function chmodPrivate(path, mode) {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
function checkedPrivateComponent(component) {
|
|
26
|
-
if (!component
|
|
27
|
+
if (!component
|
|
28
|
+
|| component === "."
|
|
29
|
+
|| component === ".."
|
|
30
|
+
|| basename(component) !== component
|
|
31
|
+
|| /[\\/]/.test(component)
|
|
32
|
+
|| component.includes("\0")) {
|
|
27
33
|
throw new Error(`invalid private Hara state path component '${component}'`);
|
|
28
34
|
}
|
|
29
35
|
return component;
|
|
@@ -128,6 +134,280 @@ export function ensurePrivateStateSubdirectory(base, components, tightenExisting
|
|
|
128
134
|
}
|
|
129
135
|
return parent;
|
|
130
136
|
}
|
|
137
|
+
/** Bind one immediate file below a symlink-free, owner-only Hara state directory. */
|
|
138
|
+
export function bindPrivateHaraStateFile(home, subdirectories, filename) {
|
|
139
|
+
const directory = ensurePrivateStateSubdirectory(home, [".hara", ...subdirectories]);
|
|
140
|
+
const name = checkedPrivateComponent(filename);
|
|
141
|
+
const path = join(directory.path, name);
|
|
142
|
+
if (dirname(path) !== directory.path)
|
|
143
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
144
|
+
verifyPrivateDirectory(directory);
|
|
145
|
+
return { directory, path };
|
|
146
|
+
}
|
|
147
|
+
function privateReadLimit(maxBytes) {
|
|
148
|
+
const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
|
|
149
|
+
return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
|
|
150
|
+
}
|
|
151
|
+
function readFdBytes(fd, count) {
|
|
152
|
+
const out = Buffer.allocUnsafe(count);
|
|
153
|
+
let offset = 0;
|
|
154
|
+
while (offset < count) {
|
|
155
|
+
const read = readSync(fd, out, offset, count - offset, offset);
|
|
156
|
+
if (!read)
|
|
157
|
+
break;
|
|
158
|
+
offset += read;
|
|
159
|
+
}
|
|
160
|
+
return out.subarray(0, offset);
|
|
161
|
+
}
|
|
162
|
+
/** Synchronous private-state reader for startup/auth APIs that cannot make their public contract async. */
|
|
163
|
+
export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
164
|
+
const limit = privateReadLimit(maxBytes);
|
|
165
|
+
let before;
|
|
166
|
+
try {
|
|
167
|
+
before = lstatSync(path);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error?.code === "ENOENT")
|
|
171
|
+
return null;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
if (before.isSymbolicLink())
|
|
175
|
+
throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
|
|
176
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
177
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
178
|
+
try {
|
|
179
|
+
let info = fstatSync(fd);
|
|
180
|
+
verifyOpenedRegularFileSync(path, info, {
|
|
181
|
+
action: "read private Hara state",
|
|
182
|
+
rejectHardLinks: true,
|
|
183
|
+
protectSensitive: false,
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
fchmodSync(fd, 0o600);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (process.platform !== "win32")
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
// chmod may update ctime; capture the authoritative baseline afterwards.
|
|
193
|
+
info = fstatSync(fd);
|
|
194
|
+
if (info.size > limit)
|
|
195
|
+
throw new FileReadLimitError(path, limit);
|
|
196
|
+
const bytes = readFdBytes(fd, Math.min(limit + 1, info.size + 1));
|
|
197
|
+
if (bytes.length > limit)
|
|
198
|
+
throw new FileReadLimitError(path, limit);
|
|
199
|
+
const latest = fstatSync(fd);
|
|
200
|
+
verifyOpenedRegularFileSync(path, latest, {
|
|
201
|
+
action: "read private Hara state",
|
|
202
|
+
rejectHardLinks: true,
|
|
203
|
+
protectSensitive: false,
|
|
204
|
+
});
|
|
205
|
+
if (latest.dev !== info.dev
|
|
206
|
+
|| latest.ino !== info.ino
|
|
207
|
+
|| latest.size !== info.size
|
|
208
|
+
|| latest.mtimeMs !== info.mtimeMs
|
|
209
|
+
|| latest.ctimeMs !== info.ctimeMs)
|
|
210
|
+
throw new Error(`private Hara state file changed while reading: '${path}'`);
|
|
211
|
+
return {
|
|
212
|
+
text: decodeUtf8Strict(bytes, path),
|
|
213
|
+
dev: latest.dev,
|
|
214
|
+
ino: latest.ino,
|
|
215
|
+
mode: latest.mode & 0o777,
|
|
216
|
+
nlink: latest.nlink,
|
|
217
|
+
size: latest.size,
|
|
218
|
+
mtimeMs: latest.mtimeMs,
|
|
219
|
+
ctimeMs: latest.ctimeMs,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function samePrivateFile(path, expected) {
|
|
227
|
+
const info = lstatSync(path);
|
|
228
|
+
return (info.isFile()
|
|
229
|
+
&& !info.isSymbolicLink()
|
|
230
|
+
&& info.dev === expected.dev
|
|
231
|
+
&& info.ino === expected.ino
|
|
232
|
+
&& (info.mode & 0o777) === expected.mode
|
|
233
|
+
&& info.nlink === expected.nlink);
|
|
234
|
+
}
|
|
235
|
+
function restorePrivateClaim(claimed, target, expected) {
|
|
236
|
+
if (!samePrivateFile(claimed, expected)) {
|
|
237
|
+
throw new Error(`private Hara state claim changed; original entry is preserved at '${claimed}'`);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
linkSync(claimed, target);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error?.code === "EEXIST") {
|
|
244
|
+
throw new Error(`another entry appeared at '${target}'; original entry is preserved at '${claimed}'`);
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const linked = { ...expected, nlink: expected.nlink + 1 };
|
|
249
|
+
if (!samePrivateFile(claimed, linked) || !samePrivateFile(target, linked)) {
|
|
250
|
+
throw new Error(`private Hara state restore changed; original entry is preserved at '${claimed}'`);
|
|
251
|
+
}
|
|
252
|
+
unlinkSync(claimed);
|
|
253
|
+
}
|
|
254
|
+
function syncPrivateDirectory(path) {
|
|
255
|
+
try {
|
|
256
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
257
|
+
const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
|
258
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
|
|
259
|
+
try {
|
|
260
|
+
if (fstatSync(fd).isDirectory())
|
|
261
|
+
fsyncSync(fd);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
closeSync(fd);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* Directory fsync is not portable; the staged file itself is always fsynced. */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
|
|
273
|
+
* are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
|
|
274
|
+
*/
|
|
275
|
+
export function writePrivateStateFileSync(binding, text) {
|
|
276
|
+
const { directory, path } = binding;
|
|
277
|
+
verifyPrivateDirectory(directory);
|
|
278
|
+
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
279
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
280
|
+
}
|
|
281
|
+
const existing = readPrivateStateFileSnapshotSync(path);
|
|
282
|
+
verifyPrivateDirectory(directory);
|
|
283
|
+
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
284
|
+
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
285
|
+
let fd;
|
|
286
|
+
let staged;
|
|
287
|
+
try {
|
|
288
|
+
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
|
|
289
|
+
writeFileSync(fd, text, "utf8");
|
|
290
|
+
try {
|
|
291
|
+
fchmodSync(fd, 0o600);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (process.platform !== "win32")
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
fsyncSync(fd);
|
|
298
|
+
const stagedInfo = fstatSync(fd);
|
|
299
|
+
if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
|
|
300
|
+
throw new Error("unsafe private Hara state staging inode");
|
|
301
|
+
staged = {
|
|
302
|
+
dev: stagedInfo.dev,
|
|
303
|
+
ino: stagedInfo.ino,
|
|
304
|
+
mode: stagedInfo.mode & 0o777,
|
|
305
|
+
nlink: stagedInfo.nlink,
|
|
306
|
+
};
|
|
307
|
+
closeSync(fd);
|
|
308
|
+
fd = undefined;
|
|
309
|
+
if (!existing) {
|
|
310
|
+
try {
|
|
311
|
+
verifyPrivateDirectory(directory);
|
|
312
|
+
linkSync(temp, path);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (error?.code === "EEXIST")
|
|
316
|
+
throw new Error(`private Hara state file changed before create: '${path}'`);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const claimed = join(directory.path, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
|
|
322
|
+
try {
|
|
323
|
+
verifyPrivateDirectory(directory);
|
|
324
|
+
renameSync(path, claimed);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error?.code === "ENOENT")
|
|
328
|
+
throw new Error(`private Hara state file changed before replace: '${path}'`);
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
let verifiedClaim = false;
|
|
332
|
+
try {
|
|
333
|
+
const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
|
|
334
|
+
verifiedClaim = Boolean(claimedSnapshot
|
|
335
|
+
&& claimedSnapshot.dev === existing.dev
|
|
336
|
+
&& claimedSnapshot.ino === existing.ino
|
|
337
|
+
&& claimedSnapshot.mode === existing.mode
|
|
338
|
+
&& claimedSnapshot.nlink === existing.nlink
|
|
339
|
+
&& claimedSnapshot.text === existing.text);
|
|
340
|
+
if (!verifiedClaim)
|
|
341
|
+
throw new Error(`private Hara state file changed before replace: '${path}'`);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
try {
|
|
345
|
+
restorePrivateClaim(claimed, path, existing);
|
|
346
|
+
}
|
|
347
|
+
catch (restoreError) {
|
|
348
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
verifyPrivateDirectory(directory);
|
|
354
|
+
linkSync(temp, path);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
let recovery = "";
|
|
358
|
+
try {
|
|
359
|
+
restorePrivateClaim(claimed, path, existing);
|
|
360
|
+
}
|
|
361
|
+
catch (restoreError) {
|
|
362
|
+
recovery = `; original entry is retained: ${restoreError?.message ?? String(restoreError)}`;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
|
|
365
|
+
}
|
|
366
|
+
if (verifiedClaim) {
|
|
367
|
+
try {
|
|
368
|
+
if (samePrivateFile(claimed, existing))
|
|
369
|
+
unlinkSync(claimed);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// The new entry is already committed. Retain a changed/unremovable unpredictable claim instead of
|
|
373
|
+
// risking deletion of a concurrently supplied path; it contains only the previous private state.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
|
|
378
|
+
if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
|
|
379
|
+
throw new Error(`private Hara state staging identity changed during commit: '${path}'`);
|
|
380
|
+
}
|
|
381
|
+
unlinkSync(temp);
|
|
382
|
+
const committed = lstatSync(path);
|
|
383
|
+
if (!staged
|
|
384
|
+
|| !committed.isFile()
|
|
385
|
+
|| committed.isSymbolicLink()
|
|
386
|
+
|| committed.dev !== staged.dev
|
|
387
|
+
|| committed.ino !== staged.ino
|
|
388
|
+
|| committed.nlink !== 1
|
|
389
|
+
|| (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
|
|
390
|
+
throw new Error(`private Hara state file changed during commit: '${path}'`);
|
|
391
|
+
verifyPrivateDirectory(directory);
|
|
392
|
+
syncPrivateDirectory(directory.path);
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (fd !== undefined)
|
|
396
|
+
try {
|
|
397
|
+
closeSync(fd);
|
|
398
|
+
}
|
|
399
|
+
catch { /* preserve original error */ }
|
|
400
|
+
if (staged) {
|
|
401
|
+
try {
|
|
402
|
+
if (samePrivateFile(temp, staged))
|
|
403
|
+
unlinkSync(temp);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
/* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
131
411
|
/** Open/read one internal state file without following aliases, reject hard links, and repair mode by fd. */
|
|
132
412
|
export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
133
413
|
const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
|
|
@@ -185,7 +465,7 @@ export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_REA
|
|
|
185
465
|
|| after.ctimeMs !== before.ctimeMs)
|
|
186
466
|
throw new Error(`private Hara state file changed while reading: '${path}'`);
|
|
187
467
|
return {
|
|
188
|
-
text: Buffer.concat(chunks, total)
|
|
468
|
+
text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
|
|
189
469
|
dev: after.dev,
|
|
190
470
|
ino: after.ino,
|
|
191
471
|
mode: after.mode & 0o777,
|