@nanhara/hara 0.130.0 → 0.130.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 CHANGED
@@ -5,6 +5,25 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.130.2 — 2026-07-21 — Weixin delivery identity
9
+
10
+ - Personal WeChat iLink messages now pass valid `message_id` and `create_time_ms` metadata into Hara's
11
+ cross-restart stale-event, deduplication, and no-rerun boundary.
12
+ - Missing or invalid metadata remains genuinely optional: the adapter omits `messageId` and `createdAtMs`
13
+ instead of adding own properties whose value is `undefined`.
14
+ - The Feishu SDK's transitive `protobufjs` parser is pinned to the patched 7.6.5 release.
15
+ - Upgrade with `npm i -g @nanhara/hara@0.130.2`.
16
+
17
+ ## 0.130.1 — 2026-07-21 — Windows private-state portability
18
+
19
+ - `hara serve` no longer calls the inapplicable POSIX `fchmod` operation on Windows discovery
20
+ directory and file handles. Official Windows standalone builds can now create the authenticated
21
+ `serve.json` record instead of exiting with `EPERM`.
22
+ - The same descriptor-mode boundary is shared by all private-state readers and writers: Windows
23
+ retains file type, identity, no-replace, atomic-write, and symlink/reparse-point checks while
24
+ omitting only POSIX owner bits; macOS and Linux still fail closed if permission tightening fails.
25
+ - Upgrade with `npm i -g @nanhara/hara@0.130.1`.
26
+
8
27
  ## 0.130.0 — 2026-07-20 — ordered task-state delivery
9
28
 
10
29
  - Typed `event.task_state` notifications now carry a server-stream identity and a positive,
@@ -0,0 +1,14 @@
1
+ import { fchmodSync } from "node:fs";
2
+ /**
3
+ * Tighten an already-verified descriptor on platforms that implement POSIX ownership modes.
4
+ *
5
+ * Windows Node/Bun may expose `fchmodSync`, but the underlying handle can reject it with EPERM and
6
+ * Windows ACLs do not implement these POSIX owner bits. Callers must retain their type, identity,
7
+ * no-replace, and atomic-write checks; only the inapplicable mode operation is omitted on Windows.
8
+ * POSIX errors deliberately propagate so a failed private-state repair remains fail-closed.
9
+ */
10
+ export function tightenPrivateDescriptorMode(fd, mode, platform = process.platform, writeMode = fchmodSync) {
11
+ if (platform === "win32")
12
+ return;
13
+ writeMode(fd, mode);
14
+ }
@@ -45,6 +45,19 @@ const WEIXIN_CDN_ALLOWLIST = new Set([
45
45
  const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
46
46
  const num = (v) => (typeof v === "number" ? v : 0);
47
47
  const str = (v) => (v == null ? "" : String(v));
48
+ function stableWeixinMessageId(value) {
49
+ if (typeof value === "string")
50
+ return value.trim() || undefined;
51
+ if (Number.isSafeInteger(value))
52
+ return String(value);
53
+ return undefined;
54
+ }
55
+ function weixinCreatedAtMs(value) {
56
+ const parsed = Number(value);
57
+ if (!Number.isFinite(parsed) || parsed <= 0)
58
+ return undefined;
59
+ return Math.trunc(parsed);
60
+ }
48
61
  // ── pure protocol helpers (unit-tested) ──────────────────────────────────────
49
62
  /** X-WECHAT-UIN: base64 of the decimal string of a random uint32 (regenerated per request). */
50
63
  export function randomWechatUin() {
@@ -145,7 +158,19 @@ export function parseWeixinMessage(msg, accountId) {
145
158
  // This tells it the text is already transcribed so it just answers.
146
159
  const isVoice = !items.some((i) => i?.type === ITEM_TEXT) && items.some((i) => i?.type === 3);
147
160
  const tagged = isVoice ? `(The user sent this as a voice message; it is already transcribed to text below — just reply to it normally, you don't have or need the audio.)\n\n${text}` : text;
148
- return { inbound: { chatId: from, userId: from, userName: from, text: tagged }, contextToken: str(msg?.context_token).trim() };
161
+ const messageId = stableWeixinMessageId(msg?.message_id);
162
+ const createdAtMs = weixinCreatedAtMs(msg?.create_time_ms);
163
+ return {
164
+ inbound: {
165
+ chatId: from,
166
+ userId: from,
167
+ userName: from,
168
+ text: tagged,
169
+ ...(messageId ? { messageId } : {}),
170
+ ...(createdAtMs === undefined ? {} : { createdAtMs }),
171
+ },
172
+ contextToken: str(msg?.context_token).trim(),
173
+ };
149
174
  }
150
175
  /** iLink signals expiry via -14, or -2 + errmsg "unknown error" (a stale-session masquerading as rate-limit). */
151
176
  export function isSessionExpired(ret, errcode, errmsg) {
@@ -1,12 +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, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, chmodSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  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
9
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
+ import { tightenPrivateDescriptorMode } from "../fs-permissions.js";
10
11
  import { sameOpenedFileIdentity } from "../fs-identity.js";
11
12
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
12
13
  const tightenedHomes = new Set();
@@ -81,7 +82,7 @@ function verifyAndTightenPrivateDirectory(path) {
81
82
  if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
82
83
  throw new Error(`private Hara state directory changed while opening: '${path}'`);
83
84
  }
84
- fchmodSync(fd, 0o700);
85
+ tightenPrivateDescriptorMode(fd, 0o700);
85
86
  }
86
87
  finally {
87
88
  closeSync(fd);
@@ -190,13 +191,7 @@ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_
190
191
  rejectHardLinks: true,
191
192
  protectSensitive: false,
192
193
  });
193
- try {
194
- fchmodSync(fd, 0o600);
195
- }
196
- catch (error) {
197
- if (process.platform !== "win32")
198
- throw error;
199
- }
194
+ tightenPrivateDescriptorMode(fd, 0o600);
200
195
  // chmod may update ctime; capture the authoritative baseline afterwards.
201
196
  info = fstatSync(fd);
202
197
  if (info.size > limit)
@@ -334,13 +329,7 @@ export function writePrivateStateBytesOnceSync(binding, bytes) {
334
329
  try {
335
330
  fd = openSync(temp, "wx", 0o600);
336
331
  writeFileSync(fd, bytes);
337
- try {
338
- fchmodSync(fd, 0o600);
339
- }
340
- catch (error) {
341
- if (process.platform !== "win32")
342
- throw error;
343
- }
332
+ tightenPrivateDescriptorMode(fd, 0o600);
344
333
  fsyncSync(fd);
345
334
  const stagedInfo = fstatSync(fd);
346
335
  if (!stagedInfo.isFile() || stagedInfo.nlink !== 1) {
@@ -422,13 +411,7 @@ export function writePrivateStateFileSync(binding, text, options = {}) {
422
411
  // Bun's Windows numeric-open compatibility can otherwise turn this valid create into a false ENOENT.
423
412
  fd = openSync(temp, "wx", 0o600);
424
413
  writeFileSync(fd, text, "utf8");
425
- try {
426
- fchmodSync(fd, 0o600);
427
- }
428
- catch (error) {
429
- if (process.platform !== "win32")
430
- throw error;
431
- }
414
+ tightenPrivateDescriptorMode(fd, 0o600);
432
415
  fsyncSync(fd);
433
416
  const stagedInfo = fstatSync(fd);
434
417
  if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
@@ -5,7 +5,7 @@
5
5
  // (no import cycle back into the CLI entry).
6
6
  import { WebSocketServer } from "ws";
7
7
  import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
8
- import { closeSync, constants as fsConstants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
8
+ import { closeSync, constants as fsConstants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { isAbsolute, join, relative, sep } from "node:path";
11
11
  import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
@@ -35,6 +35,7 @@ import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } fro
35
35
  import { taskLifecycleEvent, } from "./task-events.js";
36
36
  import { readModelContextFileSync } from "../fs-read.js";
37
37
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
38
+ import { tightenPrivateDescriptorMode } from "../fs-permissions.js";
38
39
  import { sameOpenedFileIdentity } from "../fs-identity.js";
39
40
  import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
40
41
  import { ArtifactStoreError, commitArtifact, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, revertArtifact, } from "../artifacts/store.js";
@@ -81,7 +82,7 @@ const ensurePrivateDiscoveryDir = (dir) => {
81
82
  throw new Error(`${dir} must be a private directory, not a symlink`);
82
83
  // mkdir's mode does not affect a legacy directory. Operate through the verified descriptor so a path
83
84
  // replacement cannot redirect chmod to a symlink target between validation and permission tightening.
84
- fchmodSync(fd, 0o700);
85
+ tightenPrivateDescriptorMode(fd, 0o700);
85
86
  }
86
87
  finally {
87
88
  if (fd !== undefined)
@@ -173,7 +174,7 @@ const writeDiscovery = async (dir, path, record) => {
173
174
  let fd;
174
175
  try {
175
176
  fd = openSync(temp, "wx", 0o600);
176
- fchmodSync(fd, 0o600);
177
+ tightenPrivateDescriptorMode(fd, 0o600);
177
178
  writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
178
179
  fsyncSync(fd);
179
180
  closeSync(fd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.130.0",
3
+ "version": "0.130.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"
@@ -75,7 +75,8 @@
75
75
  },
76
76
  "overrides": {
77
77
  "@larksuiteoapi/node-sdk": {
78
- "axios": "1.18.1"
78
+ "axios": "1.18.1",
79
+ "protobufjs": "7.6.5"
79
80
  }
80
81
  }
81
82
  }