@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/dist/config.js CHANGED
@@ -1,9 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { homedir } from "node:os";
3
2
  import { join, dirname, resolve } from "node:path";
4
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
5
4
  import { agentMaxRounds, agentRunTimeoutMs } from "./agent/limits.js";
6
- import { ensurePrivateHaraState } from "./security/private-state.js";
5
+ import { bindPrivateHaraStateFile, ensurePrivateHaraState, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "./security/private-state.js";
7
6
  import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
8
7
  import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
9
8
  import { isHomeWorkspace } from "./context/workspace-scope.js";
@@ -109,15 +108,11 @@ function configRecord(value) {
109
108
  }
110
109
  export function readRawConfig() {
111
110
  ensurePrivateHaraState();
112
- const p = configPath();
113
- if (!existsSync(p))
114
- return {};
115
111
  try {
116
- const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_GLOBAL_CONFIG_BYTES, {
117
- action: "read global config",
118
- protectSensitive: false,
119
- rejectHardLinks: true,
120
- });
112
+ const binding = bindPrivateHaraStateFile(homedir(), [], "config.json");
113
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_GLOBAL_CONFIG_BYTES);
114
+ if (!snapshot)
115
+ return {};
121
116
  return configRecord(JSON.parse(snapshot.text));
122
117
  }
123
118
  catch {
@@ -239,91 +234,33 @@ function readProjectConfig(cwd) {
239
234
  }
240
235
  return {};
241
236
  }
242
- function existingConfigIdentity(path) {
243
- try {
244
- const info = lstatSync(path);
245
- if (info.isSymbolicLink())
246
- throw new Error(`refusing global config write: '${path}' is a symbolic link`);
247
- if (!info.isFile())
248
- throw new Error(`refusing global config write: '${path}' is not a regular file`);
249
- if (info.nlink !== 1)
250
- throw new Error(`refusing global config write: '${path}' is hard-linked`);
251
- return { dev: info.dev, ino: info.ino };
252
- }
253
- catch (error) {
254
- if (error?.code === "ENOENT")
255
- return null;
256
- throw error;
257
- }
258
- }
259
- /** Atomically replace the global 0600 config without opening its current directory entry for write. */
260
- function persistConfig(p, cfg) {
237
+ /** Atomically replace the global 0600 config through the shared private-state CAS boundary. */
238
+ function persistConfig(cfg) {
261
239
  ensurePrivateHaraState();
262
- const parent = dirname(p);
263
- mkdirSync(parent, { recursive: true, mode: 0o700 });
264
- const parentInfo = lstatSync(parent);
265
- const canonicalParent = realpathSync.native(parent);
266
- if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) {
267
- throw new Error(`refusing global config write: '${parent}' is not a canonical directory`);
268
- }
269
- const existing = existingConfigIdentity(p);
270
- const temp = join(parent, `.config-${process.pid}-${randomUUID()}.tmp`);
271
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
272
- let fd;
273
- try {
274
- fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
275
- writeFileSync(fd, JSON.stringify(cfg, null, 2) + "\n", "utf8");
276
- fsyncSync(fd);
277
- const staged = fstatSync(fd);
278
- if (!staged.isFile() || staged.nlink !== 1)
279
- throw new Error("refusing global config write: unsafe staging inode");
280
- closeSync(fd);
281
- fd = undefined;
282
- const parentAfter = lstatSync(parent);
283
- if (!parentAfter.isDirectory() || parentAfter.isSymbolicLink() ||
284
- parentAfter.dev !== parentInfo.dev || parentAfter.ino !== parentInfo.ino ||
285
- realpathSync.native(parent) !== canonicalParent)
286
- throw new Error(`refusing global config write: '${parent}' changed before commit`);
287
- const current = existingConfigIdentity(p);
288
- if ((existing === null) !== (current === null) || (existing && current && (existing.dev !== current.dev || existing.ino !== current.ino))) {
289
- throw new Error(`refusing global config write: '${p}' changed before commit`);
290
- }
291
- // rename replaces the path entry; it never follows an alias planted after the last identity check.
292
- renameSync(temp, p);
293
- const committed = lstatSync(p);
294
- if (!committed.isFile() || committed.isSymbolicLink() || committed.dev !== staged.dev || committed.ino !== staged.ino || committed.nlink !== 1) {
295
- throw new Error(`global config changed during commit: '${p}'`);
296
- }
297
- }
298
- finally {
299
- if (fd !== undefined)
300
- try {
301
- closeSync(fd);
302
- }
303
- catch { /* preserve original error */ }
304
- try {
305
- rmSync(temp, { force: true });
306
- }
307
- catch { /* preserve original error */ }
308
- }
240
+ const binding = bindPrivateHaraStateFile(homedir(), [], "config.json");
241
+ writePrivateStateFileSync(binding, JSON.stringify(cfg, null, 2) + "\n");
242
+ }
243
+ /** Mutate global config without exposing a second direct-write implementation to feature modules. */
244
+ export function updateRawConfig(mutate) {
245
+ const config = readRawConfig();
246
+ const replacement = mutate(config);
247
+ persistConfig(replacement ?? config);
309
248
  }
310
249
  export function writeConfigValue(key, value) {
311
- const p = configPath();
312
- const cfg = readRawConfig();
313
- cfg[key] = value;
314
- persistConfig(p, cfg);
250
+ updateRawConfig((config) => {
251
+ config[key] = value;
252
+ });
315
253
  }
316
254
  /** Record (or clear, with cap=null) a confirmed per-model vision capability in `modelVision`. */
317
255
  export function setModelVisionOverride(model, cap) {
318
- const p = configPath();
319
- const cfg = readRawConfig();
320
- const map = cfg.modelVision && typeof cfg.modelVision === "object" ? cfg.modelVision : {};
321
- if (cap === null)
322
- delete map[model];
323
- else
324
- map[model] = cap;
325
- cfg.modelVision = map;
326
- persistConfig(p, cfg);
256
+ updateRawConfig((config) => {
257
+ const map = config.modelVision && typeof config.modelVision === "object" ? config.modelVision : {};
258
+ if (cap === null)
259
+ delete map[model];
260
+ else
261
+ map[model] = cap;
262
+ config.modelVision = map;
263
+ });
327
264
  }
328
265
  /**
329
266
  * Effective config. Precedence (high→low): env vars > allowed/trusted project `.hara/config.json` >
package/dist/desk.js CHANGED
@@ -4,13 +4,15 @@
4
4
  //
5
5
  // Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
6
6
  // `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
7
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
8
7
  import { homedir } from "node:os";
9
- import { join } from "node:path";
10
- const credsPath = () => join(homedir(), ".hara", "desk.json");
8
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "./security/private-state.js";
11
9
  export function loadCreds() {
12
10
  try {
13
- const c = JSON.parse(readFileSync(credsPath(), "utf8"));
11
+ const binding = bindPrivateHaraStateFile(homedir(), [], "desk.json");
12
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
13
+ if (!snapshot)
14
+ return null;
15
+ const c = JSON.parse(snapshot.text);
14
16
  return c.url && c.token ? c : null;
15
17
  }
16
18
  catch {
@@ -18,16 +20,8 @@ export function loadCreds() {
18
20
  }
19
21
  }
20
22
  export function saveCreds(c) {
21
- const dir = join(homedir(), ".hara");
22
- mkdirSync(dir, { recursive: true });
23
- const p = credsPath();
24
- writeFileSync(p, JSON.stringify(c, null, 2));
25
- try {
26
- chmodSync(p, 0o600);
27
- }
28
- catch {
29
- /* best-effort on non-posix */
30
- }
23
+ const binding = bindPrivateHaraStateFile(homedir(), [], "desk.json");
24
+ writePrivateStateFileSync(binding, JSON.stringify(c, null, 2) + "\n");
31
25
  }
32
26
  /** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
33
27
  * the server's error message (so the CLI can surface a real reason, not "request failed"). */
package/dist/fs-read.js CHANGED
@@ -10,6 +10,27 @@ export class BinaryFileError extends Error {
10
10
  this.name = "BinaryFileError";
11
11
  }
12
12
  }
13
+ export class InvalidUtf8FileError extends Error {
14
+ code = "HARA_INVALID_UTF8";
15
+ constructor(path) {
16
+ super(`${path} is not valid UTF-8; refusing a lossy text operation. ` +
17
+ "Use a binary-aware tool or explicitly transcode the file first");
18
+ this.name = "InvalidUtf8FileError";
19
+ }
20
+ }
21
+ /** Decode a complete text-file snapshot without replacing invalid bytes or dropping a UTF-8 BOM. */
22
+ export function decodeUtf8Strict(bytes, path) {
23
+ if (bytes.includes(0))
24
+ throw new BinaryFileError(path);
25
+ try {
26
+ // ignoreBOM=true means the BOM is emitted as U+FEFF. A later UTF-8 rewrite therefore preserves the
27
+ // original bytes instead of silently stripping the marker while editing otherwise valid text.
28
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
29
+ }
30
+ catch {
31
+ throw new InvalidUtf8FileError(path);
32
+ }
33
+ }
13
34
  export class NonRegularFileError extends Error {
14
35
  code = "HARA_NOT_REGULAR_FILE";
15
36
  constructor(path) {
@@ -44,7 +65,7 @@ export class ProtectedContextFileError extends Error {
44
65
  export class HardLinkedFileError extends Error {
45
66
  code = "HARA_HARD_LINKED_FILE";
46
67
  constructor(path) {
47
- super(`${path} has multiple hard links; its protected identity cannot be established safely`);
68
+ super(`${path} is hard-linked (has multiple hard links); its protected identity cannot be established safely`);
48
69
  this.name = "HardLinkedFileError";
49
70
  }
50
71
  }
@@ -143,7 +164,7 @@ export async function readVerifiedRegularFileSnapshot(path, maxBytes = MAX_EDIT_
143
164
  || latest.ctimeMs !== info.ctimeMs)
144
165
  throw new Error(`refusing to read ${path}: file changed while reading it`);
145
166
  return {
146
- text: Buffer.concat(chunks, total).toString("utf8"),
167
+ text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
147
168
  dev: info.dev,
148
169
  ino: info.ino,
149
170
  mode: info.mode & 0o777,
@@ -196,7 +217,7 @@ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_RE
196
217
  || latest.ctimeMs !== info.ctimeMs)
197
218
  throw new Error(`refusing to read ${path}: file changed while reading it`);
198
219
  return {
199
- text: bytes.toString("utf8"),
220
+ text: decodeUtf8Strict(bytes, path),
200
221
  dev: info.dev,
201
222
  ino: info.ino,
202
223
  mode: info.mode & 0o777,
@@ -278,9 +299,7 @@ export function readModelContextFileSync(path, maxBytes) {
278
299
  const bytes = readFdBytesSync(fd, Math.min(limit + 1, size + 1));
279
300
  if (bytes.length > limit)
280
301
  throw new FileReadLimitError(path, limit);
281
- if (bytes.includes(0))
282
- throw new BinaryFileError(path);
283
- return bytes.toString("utf8");
302
+ return decodeUtf8Strict(bytes, path);
284
303
  });
285
304
  }
286
305
  /** Safe bounded-prefix counterpart for @file expansion; it never materializes the remainder of a huge file. */
@@ -361,7 +380,7 @@ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
361
380
  }
362
381
  if (total > limit)
363
382
  throw new FileReadLimitError(path, limit);
364
- return { text: Buffer.concat(chunks, total).toString("utf8"), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
383
+ return { text: decodeUtf8Strict(Buffer.concat(chunks, total), path), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
365
384
  }
366
385
  finally {
367
386
  await handle.close().catch(() => { });
@@ -4,11 +4,10 @@
4
4
  // `qrcode-terminal` dep (graceful fallback to printing the URL). No encryption is needed on the text path
5
5
  // (iLink only uses crypto for media upload/download, which v1 doesn't do).
6
6
  import { homedir } from "node:os";
7
- import { join } from "node:path";
8
- import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
9
7
  import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
10
8
  import { chunkText } from "./telegram.js";
11
9
  import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
10
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "../security/private-state.js";
12
11
  const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
13
12
  const CHANNEL_VERSION = "2.2.0";
14
13
  const ILINK_APP_ID = "bot";
@@ -186,33 +185,53 @@ async function apiGet(baseUrl, endpoint, timeoutMs) {
186
185
  throw new Error(`iLink GET ${endpoint} HTTP ${res.status}: ${raw.slice(0, 200)}`);
187
186
  return JSON.parse(raw);
188
187
  }
189
- const weixinDir = () => join(homedir(), ".hara", "weixin");
190
- const credsFile = () => join(weixinDir(), "creds.json");
191
- export function loadWeixinCreds() {
188
+ function weixinStateFile(filename) {
189
+ return bindPrivateHaraStateFile(homedir(), ["weixin"], filename);
190
+ }
191
+ function accountStateFilename(accountId, suffix) {
192
+ const normalized = String(accountId ?? "").trim();
193
+ const key = /^[A-Za-z0-9_-]{1,128}$/.test(normalized)
194
+ ? normalized
195
+ : `account-${createHash("sha256").update(normalized).digest("hex")}`;
196
+ return `${key}${suffix}`;
197
+ }
198
+ /** Return peer identifiers without exposing token values or bypassing the private-state reader. */
199
+ export function weixinKnownPeers(accountId) {
192
200
  try {
193
- return existsSync(credsFile()) ? JSON.parse(readFileSync(credsFile(), "utf8")) : null;
201
+ const binding = weixinStateFile(accountStateFilename(accountId, ".context-tokens.json"));
202
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 8 * 1024 * 1024);
203
+ if (!snapshot)
204
+ return [];
205
+ const parsed = JSON.parse(snapshot.text);
206
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
207
+ return [];
208
+ return Object.entries(parsed)
209
+ .slice(-1000)
210
+ .filter(([peer, token]) => Boolean(peer) && typeof token === "string" && Boolean(token))
211
+ .map(([peer]) => peer);
194
212
  }
195
213
  catch {
196
- return null;
214
+ return [];
197
215
  }
198
216
  }
199
- function saveWeixinCreds(c) {
200
- mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
217
+ export function loadWeixinCreds() {
201
218
  try {
202
- chmodSync(weixinDir(), 0o700);
219
+ const binding = weixinStateFile("creds.json");
220
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
221
+ return snapshot ? JSON.parse(snapshot.text) : null;
203
222
  }
204
- catch { /* best effort */ }
205
- writeFileSync(credsFile(), JSON.stringify(c, null, 2), { mode: 0o600 });
206
- try {
207
- chmodSync(credsFile(), 0o600);
223
+ catch {
224
+ return null;
208
225
  }
209
- catch { /* best effort */ }
226
+ }
227
+ function saveWeixinCreds(c) {
228
+ writePrivateStateFileSync(weixinStateFile("creds.json"), JSON.stringify(c, null, 2) + "\n");
210
229
  }
211
230
  // get_updates_buf cursor — persisted so a restart resumes the message stream where it left off.
212
231
  function loadCursor(accountId) {
213
232
  try {
214
- const f = join(weixinDir(), `${accountId}.cursor`);
215
- return existsSync(f) ? readFileSync(f, "utf8") : "";
233
+ const binding = weixinStateFile(accountStateFilename(accountId, ".cursor"));
234
+ return readPrivateStateFileSnapshotSync(binding.path, 4 * 1024 * 1024)?.text ?? "";
216
235
  }
217
236
  catch {
218
237
  return "";
@@ -220,11 +239,8 @@ function loadCursor(accountId) {
220
239
  }
221
240
  function saveCursor(accountId, buf) {
222
241
  try {
223
- mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
224
- chmodSync(weixinDir(), 0o700);
225
- const file = join(weixinDir(), `${accountId}.cursor`);
226
- writeFileSync(file, buf, { mode: 0o600 });
227
- chmodSync(file, 0o600);
242
+ const binding = weixinStateFile(accountStateFilename(accountId, ".cursor"));
243
+ writePrivateStateFileSync(binding, buf);
228
244
  }
229
245
  catch {
230
246
  /* best-effort */
@@ -237,10 +253,9 @@ class TokenStore {
237
253
  constructor(accountId) {
238
254
  this.accountId = accountId;
239
255
  try {
240
- const exists = existsSync(this.file());
241
- const parsed = exists ? JSON.parse(readFileSync(this.file(), "utf8")) : {};
242
- if (exists)
243
- chmodSync(this.file(), 0o600);
256
+ const binding = this.binding();
257
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 8 * 1024 * 1024);
258
+ const parsed = snapshot ? JSON.parse(snapshot.text) : {};
244
259
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
245
260
  for (const [peer, token] of Object.entries(parsed).slice(-1000)) {
246
261
  if (peer && typeof token === "string" && token)
@@ -252,32 +267,19 @@ class TokenStore {
252
267
  this.cache.clear();
253
268
  }
254
269
  }
255
- file() {
256
- return join(weixinDir(), `${this.accountId}.context-tokens.json`);
270
+ binding() {
271
+ return weixinStateFile(accountStateFilename(this.accountId, ".context-tokens.json"));
257
272
  }
258
273
  peerKey(peer) {
259
274
  return String(peer ?? "").trim().slice(0, 256);
260
275
  }
261
276
  persist() {
262
- const temporary = `${this.file()}.${process.pid}.${randomUUID()}.tmp`;
263
277
  try {
264
- mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
265
- chmodSync(weixinDir(), 0o700);
266
- writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cache)), { mode: 0o600, flag: "wx" });
267
- renameSync(temporary, this.file());
268
- chmodSync(this.file(), 0o600);
278
+ writePrivateStateFileSync(this.binding(), JSON.stringify(Object.fromEntries(this.cache)) + "\n");
269
279
  }
270
280
  catch {
271
281
  /* best-effort */
272
282
  }
273
- finally {
274
- try {
275
- rmSync(temporary, { force: true });
276
- }
277
- catch {
278
- /* best-effort */
279
- }
280
- }
281
283
  }
282
284
  get(peer) {
283
285
  const key = this.peerKey(peer);