@nanhara/hara 0.129.0 → 0.130.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 +24 -0
- package/dist/fs-permissions.js +14 -0
- package/dist/security/private-state.js +6 -23
- package/dist/serve/protocol.js +1 -1
- package/dist/serve/server.js +23 -8
- package/dist/serve/task-events.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.1 — 2026-07-21 — Windows private-state portability
|
|
9
|
+
|
|
10
|
+
- `hara serve` no longer calls the inapplicable POSIX `fchmod` operation on Windows discovery
|
|
11
|
+
directory and file handles. Official Windows standalone builds can now create the authenticated
|
|
12
|
+
`serve.json` record instead of exiting with `EPERM`.
|
|
13
|
+
- The same descriptor-mode boundary is shared by all private-state readers and writers: Windows
|
|
14
|
+
retains file type, identity, no-replace, atomic-write, and symlink/reparse-point checks while
|
|
15
|
+
omitting only POSIX owner bits; macOS and Linux still fail closed if permission tightening fails.
|
|
16
|
+
- Upgrade with `npm i -g @nanhara/hara@0.130.1`.
|
|
17
|
+
|
|
18
|
+
## 0.130.0 — 2026-07-20 — ordered task-state delivery
|
|
19
|
+
|
|
20
|
+
- Typed `event.task_state` notifications now carry a server-stream identity and a positive,
|
|
21
|
+
monotonically increasing sequence. Desktop and other authenticated Serve clients can reject
|
|
22
|
+
duplicated or stale lifecycle transitions instead of letting a late event overwrite the current
|
|
23
|
+
task, approval, checkpoint, or completion state.
|
|
24
|
+
- Ordering spans every session and every resume within one `hara serve` process. A server restart
|
|
25
|
+
creates a new stream identity, so clients can accept the fresh sequence without confusing it with
|
|
26
|
+
an older connection.
|
|
27
|
+
- Conversation streaming remains independent from execution state, and task event payloads retain
|
|
28
|
+
the same redacted ambient-data boundary. Existing protocol-v1 clients remain compatible because
|
|
29
|
+
the ordering fields are additive.
|
|
30
|
+
- Upgrade with `npm i -g @nanhara/hara@0.130.0`.
|
|
31
|
+
|
|
8
32
|
## 0.129.0 — 2026-07-20 — durable revisions and workspace recovery
|
|
9
33
|
|
|
10
34
|
- Authenticated Serve clients can commit a user-selected file as a new immutable Artifact revision with
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/serve/protocol.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
// Server → client notifications (all carry sessionId):
|
|
43
43
|
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
44
44
|
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
|
45
|
-
// event.task_state {version,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
|
|
45
|
+
// event.task_state {version,streamId,sequence,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
|
|
46
46
|
// authoritative execution plane; clients feature-detect it via capabilities.events
|
|
47
47
|
export const PROTOCOL_VERSION = 1;
|
|
48
48
|
/** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
|
package/dist/serve/server.js
CHANGED
|
@@ -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,
|
|
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
|
|
@@ -32,9 +32,10 @@ import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from ".
|
|
|
32
32
|
import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
|
|
33
33
|
import { SessionHub, realStore } from "./sessions.js";
|
|
34
34
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
35
|
-
import { taskLifecycleEvent } from "./task-events.js";
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
tightenPrivateDescriptorMode(fd, 0o600);
|
|
177
178
|
writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
178
179
|
fsyncSync(fd);
|
|
179
180
|
closeSync(fd);
|
|
@@ -295,6 +296,7 @@ export async function startServe(opts, deps) {
|
|
|
295
296
|
// Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
|
|
296
297
|
// of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
|
|
297
298
|
const activeOperations = new Set();
|
|
299
|
+
let taskEventSequence = 0;
|
|
298
300
|
let closing = false;
|
|
299
301
|
let closePromise = null;
|
|
300
302
|
const trackActiveOperation = (operation) => {
|
|
@@ -358,10 +360,23 @@ export async function startServe(opts, deps) {
|
|
|
358
360
|
if (ws.readyState === ws.OPEN)
|
|
359
361
|
ws.send(frame);
|
|
360
362
|
};
|
|
363
|
+
const nextTaskEventCursor = () => {
|
|
364
|
+
const sequence = taskEventSequence + 1;
|
|
365
|
+
if (!Number.isSafeInteger(sequence)) {
|
|
366
|
+
throw new Error("task lifecycle event sequence exhausted");
|
|
367
|
+
}
|
|
368
|
+
return { streamId: instanceId, sequence };
|
|
369
|
+
};
|
|
370
|
+
const publishTaskState = (event) => {
|
|
371
|
+
// Commit the cursor immediately before the synchronous broadcast. Dedupe paths never consume one,
|
|
372
|
+
// while every published event has a unique position in this server-wide stream.
|
|
373
|
+
taskEventSequence = event.sequence;
|
|
374
|
+
broadcast("event.task_state", { ...event });
|
|
375
|
+
};
|
|
361
376
|
const broadcastTaskState = (session, activity) => {
|
|
362
377
|
if (!session.task)
|
|
363
378
|
return;
|
|
364
|
-
|
|
379
|
+
publishTaskState(taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity, nextTaskEventCursor()));
|
|
365
380
|
};
|
|
366
381
|
// Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
|
|
367
382
|
const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
|
|
@@ -448,13 +463,13 @@ export async function startServe(opts, deps) {
|
|
|
448
463
|
const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
|
|
449
464
|
if (!s.task)
|
|
450
465
|
return;
|
|
451
|
-
const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
|
|
452
|
-
const { at: _at, ...stableEvent } = event;
|
|
466
|
+
const event = taskLifecycleEvent(sessionId, s.task, todos, activity, nextTaskEventCursor());
|
|
467
|
+
const { at: _at, streamId: _streamId, sequence: _sequence, ...stableEvent } = event;
|
|
453
468
|
const signature = JSON.stringify(stableEvent);
|
|
454
469
|
if (signature === lastTaskStateSignature)
|
|
455
470
|
return;
|
|
456
471
|
lastTaskStateSignature = signature;
|
|
457
|
-
|
|
472
|
+
publishTaskState(event);
|
|
458
473
|
};
|
|
459
474
|
broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
|
|
460
475
|
emitTaskState({ state: "running", phase: "starting" });
|
|
@@ -5,7 +5,14 @@ function bounded(value, max) {
|
|
|
5
5
|
}
|
|
6
6
|
/** Build the single task-state shape consumed by Desktop, IDE clients, and the companion. Conversation
|
|
7
7
|
* streaming remains separate; this event is the authoritative execution/status plane. */
|
|
8
|
-
export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = new Date().toISOString()) {
|
|
8
|
+
export function taskLifecycleEvent(sessionId, task, todos = [], activity, cursor, at = new Date().toISOString()) {
|
|
9
|
+
const streamId = cursor.streamId.trim();
|
|
10
|
+
if (!streamId || streamId.length > 128) {
|
|
11
|
+
throw new Error("task lifecycle streamId must contain 1-128 characters");
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isSafeInteger(cursor.sequence) || cursor.sequence <= 0) {
|
|
14
|
+
throw new Error("task lifecycle sequence must be a positive safe integer");
|
|
15
|
+
}
|
|
9
16
|
const current = todos.find((todo) => todo.status === "in_progress")
|
|
10
17
|
?? todos.find((todo) => todo.status === "pending");
|
|
11
18
|
const detail = bounded(activity.detail, 500);
|
|
@@ -22,6 +29,8 @@ export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = n
|
|
|
22
29
|
: task.status;
|
|
23
30
|
return {
|
|
24
31
|
version: TASK_LIFECYCLE_EVENT_VERSION,
|
|
32
|
+
streamId,
|
|
33
|
+
sequence: cursor.sequence,
|
|
25
34
|
sessionId,
|
|
26
35
|
taskId: task.id,
|
|
27
36
|
turnId: task.turnId,
|