@nanhara/hara 0.121.1 → 0.122.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
package/dist/fs-write.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
2
2
  // renamed into place so a killed process never leaves a half-written source file.
3
- import { dirname, join } from "node:path";
4
- import { link, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { constants, linkSync, lstatSync, readlinkSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
6
+ import { lstat, mkdir, open, realpath, rmdir, stat } from "node:fs/promises";
7
+ import { NonRegularFileError, readRegularFileSnapshotNoFollow } from "./fs-read.js";
8
+ import { canonicalizeProspectivePath, lexicalSensitiveFileReason, sensitiveFileError, } from "./security/sensitive-files.js";
5
9
  export class FileChangedError extends Error {
6
10
  code = "HARA_FILE_CHANGED";
7
11
  constructor(path) {
@@ -10,6 +14,145 @@ export class FileChangedError extends Error {
10
14
  }
11
15
  }
12
16
  let tempSequence = 0;
17
+ // Internal Hara control-plane writers need the same identity/CAS guarantees as coding tools, but the
18
+ // public protected-file policy intentionally rejects those destinations. Keep exemptions as object identity
19
+ // rather than a forgeable boolean on AtomicWriteBoundary: only the narrow, destination-specific binders below
20
+ // can mint one.
21
+ const privateStateBoundaries = new WeakSet();
22
+ function protectedWriteError(path, action) {
23
+ const denied = sensitiveFileError(path, action);
24
+ return denied ? new Error(denied) : null;
25
+ }
26
+ function verifyDirectoryIdentity(boundary) {
27
+ const current = lstatSync(boundary.ancestor.path);
28
+ const canonical = realpathSync.native(boundary.ancestor.path);
29
+ if (!current.isDirectory()
30
+ || current.isSymbolicLink()
31
+ || current.dev !== boundary.ancestor.dev
32
+ || current.ino !== boundary.ancestor.ino
33
+ || canonical !== boundary.ancestor.path)
34
+ throw new FileChangedError(boundary.target);
35
+ }
36
+ export function verifyAtomicWriteBoundary(boundary) {
37
+ verifyDirectoryIdentity(boundary);
38
+ if (!privateStateBoundaries.has(boundary)) {
39
+ const denied = protectedWriteError(boundary.target, boundary.action);
40
+ if (denied)
41
+ throw denied;
42
+ }
43
+ }
44
+ /**
45
+ * Bind one internal private-Hara-state file below a directory whose symlink-free construction and mode
46
+ * were already verified by the caller. The exemption cannot be used for `.env`/credential files or an
47
+ * arbitrary descendant: the target must be an immediate child classified specifically as Hara state.
48
+ */
49
+ export function bindHaraPrivateStateWritePath(path, stateDir, action) {
50
+ const dir = resolve(stateDir);
51
+ const target = resolve(path);
52
+ if (dirname(target) !== dir)
53
+ throw new Error(`private Hara state target must be an immediate child of ${dir}`);
54
+ if (lexicalSensitiveFileReason(target) !== "private Hara state") {
55
+ throw new Error(`refusing private Hara state exemption for ${target}`);
56
+ }
57
+ const canonical = realpathSync.native(dir);
58
+ const info = lstatSync(dir);
59
+ if (!info.isDirectory() || info.isSymbolicLink() || canonical !== dir) {
60
+ throw new Error(`refusing private Hara state write: '${dir}' is not a canonical directory`);
61
+ }
62
+ const boundary = {
63
+ target,
64
+ action,
65
+ ancestor: { path: dir, dev: info.dev, ino: info.ino },
66
+ };
67
+ privateStateBoundaries.add(boundary);
68
+ verifyAtomicWriteBoundary(boundary);
69
+ return boundary;
70
+ }
71
+ /** Bind the personal identity pin without granting a general protected-file bypass. The final directory
72
+ * entry is deliberately not resolved: callers can no-follow inspect it and atomic CAS will reject any
73
+ * symlink or replacement that appears after preflight. */
74
+ export function bindProfilePinWritePath(path, action = "write profile pin") {
75
+ const requested = resolve(path);
76
+ if (basename(requested) !== ".hara-profile")
77
+ throw new Error("profile pin target must be named .hara-profile");
78
+ const parent = realpathSync.native(dirname(requested));
79
+ const target = join(parent, basename(requested));
80
+ if (lexicalSensitiveFileReason(target) !== "private Hara routing state") {
81
+ throw new Error(`refusing profile pin exemption for ${target}`);
82
+ }
83
+ const info = lstatSync(parent);
84
+ if (!info.isDirectory() || info.isSymbolicLink())
85
+ throw new Error(`${parent} is not a canonical directory`);
86
+ const boundary = {
87
+ target,
88
+ action,
89
+ ancestor: { path: parent, dev: info.dev, ino: info.ino },
90
+ };
91
+ privateStateBoundaries.add(boundary);
92
+ verifyAtomicWriteBoundary(boundary);
93
+ return boundary;
94
+ }
95
+ /** Bind a directory entry while preserving its final symlink topology (used by transactional deletes). */
96
+ export function bindAtomicParentEntryPath(path, action = "write") {
97
+ const lexicalDenied = protectedWriteError(path, action);
98
+ if (lexicalDenied)
99
+ throw lexicalDenied;
100
+ const requested = resolve(path);
101
+ const parent = realpathSync.native(dirname(requested));
102
+ const target = join(parent, basename(requested));
103
+ const canonicalDenied = protectedWriteError(target, action);
104
+ if (canonicalDenied)
105
+ throw canonicalDenied;
106
+ const info = lstatSync(parent);
107
+ if (!info.isDirectory() || info.isSymbolicLink())
108
+ throw new Error(`${parent} is not a directory`);
109
+ const boundary = {
110
+ target,
111
+ action,
112
+ ancestor: { path: parent, dev: info.dev, ino: info.ino },
113
+ };
114
+ verifyAtomicWriteBoundary(boundary);
115
+ return boundary;
116
+ }
117
+ /**
118
+ * Bind a direct coding-tool path to one canonical candidate and the nearest existing parent directory.
119
+ * Missing tail directories remain below that identity; a parent symlink retarget or directory replacement
120
+ * between preflight and commit therefore fails instead of silently selecting another tree.
121
+ */
122
+ export function bindAtomicWritePath(path, action = "write") {
123
+ const lexicalDenied = protectedWriteError(path, action);
124
+ if (lexicalDenied)
125
+ throw lexicalDenied;
126
+ const target = canonicalizeProspectivePath(path);
127
+ const canonicalDenied = protectedWriteError(target, action);
128
+ if (canonicalDenied)
129
+ throw canonicalDenied;
130
+ let current = dirname(target);
131
+ for (let depth = 0; depth < 128; depth++) {
132
+ try {
133
+ const canonical = realpathSync.native(current);
134
+ const info = lstatSync(canonical);
135
+ if (!info.isDirectory() || info.isSymbolicLink())
136
+ throw new Error(`${canonical} is not a directory`);
137
+ const boundary = {
138
+ target,
139
+ action,
140
+ ancestor: { path: canonical, dev: info.dev, ino: info.ino },
141
+ };
142
+ verifyAtomicWriteBoundary(boundary);
143
+ return boundary;
144
+ }
145
+ catch (error) {
146
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR")
147
+ throw error;
148
+ }
149
+ const parent = dirname(current);
150
+ if (parent === current)
151
+ throw new Error(`cannot bind parent directory for ${path}`);
152
+ current = parent;
153
+ }
154
+ throw new Error(`write path exceeds 128 components: ${path}`);
155
+ }
13
156
  async function writeTarget(path) {
14
157
  try {
15
158
  const info = await lstat(path);
@@ -24,12 +167,139 @@ async function writeTarget(path) {
24
167
  }
25
168
  return path;
26
169
  }
170
+ /** mkdir -p with ownership accounting: only a mkdir call that actually succeeded is recorded. */
171
+ async function ensureDirectory(path, created) {
172
+ try {
173
+ const info = await stat(path);
174
+ if (!info.isDirectory())
175
+ throw new Error(`${path} is not a directory`);
176
+ return;
177
+ }
178
+ catch (error) {
179
+ if (error?.code !== "ENOENT")
180
+ throw error;
181
+ }
182
+ const parent = dirname(path);
183
+ if (parent === path)
184
+ throw new Error(`cannot create directory ${path}`);
185
+ await ensureDirectory(parent, created);
186
+ try {
187
+ await mkdir(path);
188
+ const info = await lstat(path);
189
+ if (!info.isDirectory())
190
+ throw new Error(`${path} changed while it was being created`);
191
+ // Store the canonical name now. Keeping a lexical path beneath a symlink would make undo look in a
192
+ // different tree after that parent link is retargeted, silently leaving directories we created behind.
193
+ const canonical = await realpath(path);
194
+ const canonicalInfo = await lstat(canonical);
195
+ if (!canonicalInfo.isDirectory() || canonicalInfo.dev !== info.dev || canonicalInfo.ino !== info.ino) {
196
+ throw new FileChangedError(path);
197
+ }
198
+ created.push({ path: canonical, dev: info.dev, ino: info.ino, mode: info.mode & 0o777 });
199
+ }
200
+ catch (error) {
201
+ if (error?.code !== "EEXIST")
202
+ throw error;
203
+ const info = await stat(path);
204
+ if (!info.isDirectory())
205
+ throw new Error(`${path} is not a directory`);
206
+ }
207
+ }
208
+ /** Remove only directories this process actually created and that still name the same empty inode. */
209
+ export async function removeCreatedDirectories(created) {
210
+ for (let i = created.length - 1; i >= 0; i--) {
211
+ const expected = created[i];
212
+ let info;
213
+ try {
214
+ info = await lstat(expected.path);
215
+ }
216
+ catch (error) {
217
+ if (error?.code === "ENOENT")
218
+ continue;
219
+ throw error;
220
+ }
221
+ if (!info.isDirectory() || info.dev !== expected.dev || info.ino !== expected.ino || (info.mode & 0o777) !== expected.mode) {
222
+ throw new FileChangedError(expected.path);
223
+ }
224
+ await rmdir(expected.path); // ENOTEMPTY is a safe refusal: concurrent content is never removed.
225
+ }
226
+ }
227
+ /**
228
+ * Delete a previously verified quarantine entry without reopening the long verify→unlink race.
229
+ *
230
+ * The caller has already verified content through a no-follow descriptor. We atomically claim the current
231
+ * directory entry under one more unpredictable name, then perform the final no-follow identity check and
232
+ * unlink synchronously in the same JS turn. A same-process watcher therefore cannot replace the entry in
233
+ * the long asynchronous content-read window and have an unrelated inode unlinked. On any mismatch/failure,
234
+ * the claimed entry is deliberately retained and its exact recovery path is included in the error.
235
+ */
236
+ export function discardClaimedPath(path, expected) {
237
+ const disposal = join(dirname(path), `.hara-discard-${process.pid}-${randomUUID()}.tmp`);
238
+ renameSync(path, disposal);
239
+ try {
240
+ const info = lstatSync(disposal);
241
+ const isLink = expected.linkTarget !== undefined;
242
+ const same = info.dev === expected.dev &&
243
+ info.ino === expected.ino &&
244
+ (info.mode & 0o777) === expected.mode &&
245
+ info.nlink === expected.nlink &&
246
+ info.isSymbolicLink() === isLink &&
247
+ (!isLink || readlinkSync(disposal) === expected.linkTarget);
248
+ if (!same)
249
+ throw new FileChangedError(path);
250
+ unlinkSync(disposal);
251
+ }
252
+ catch (error) {
253
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; claimed entry is preserved at ${disposal}`, { cause: error });
254
+ }
255
+ }
256
+ function sameClaimedPath(path, expected) {
257
+ const info = lstatSync(path);
258
+ const isLink = expected.linkTarget !== undefined;
259
+ return (info.dev === expected.dev &&
260
+ info.ino === expected.ino &&
261
+ (info.mode & 0o777) === expected.mode &&
262
+ info.nlink === expected.nlink &&
263
+ info.isSymbolicLink() === isLink &&
264
+ (!isLink || readlinkSync(path) === expected.linkTarget));
265
+ }
266
+ /** Restore a move-claimed entry without overwriting a path that appeared concurrently. */
267
+ function restoreClaimedPath(claimed, target, expected) {
268
+ if (!sameClaimedPath(claimed, expected)) {
269
+ throw new Error(`${new FileChangedError(target).message}; claimed entry is preserved at ${claimed}`);
270
+ }
271
+ const info = lstatSync(claimed);
272
+ if (info.isDirectory()) {
273
+ // POSIX has no portable rename-no-replace for directories. Retaining is safer than overwriting a new target.
274
+ throw new Error(`cannot safely restore a concurrently claimed directory; it is preserved at ${claimed}`);
275
+ }
276
+ try {
277
+ if (expected.linkTarget !== undefined)
278
+ symlinkSync(expected.linkTarget, target);
279
+ else
280
+ linkSync(claimed, target);
281
+ }
282
+ catch (error) {
283
+ if (error?.code === "EEXIST") {
284
+ throw new Error(`another entry appeared at ${target}; the claimed entry is preserved at ${claimed}`);
285
+ }
286
+ throw error;
287
+ }
288
+ discardClaimedPath(claimed, {
289
+ ...expected,
290
+ nlink: expected.nlink + (expected.linkTarget === undefined ? 1 : 0),
291
+ });
292
+ }
27
293
  async function syncDirectory(path) {
28
294
  // Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
29
295
  // reject opening directories, so durability degrades gracefully after the file itself was synced.
30
296
  try {
31
- const handle = await open(path, "r");
297
+ // The directory name can be exchanged after rename. O_NONBLOCK plus fstat on this exact descriptor
298
+ // keeps best-effort durability from hanging forever on a replacement FIFO/device.
299
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
32
300
  try {
301
+ if (!(await handle.stat()).isDirectory())
302
+ return;
33
303
  await handle.sync();
34
304
  }
35
305
  finally {
@@ -42,12 +312,51 @@ async function syncDirectory(path) {
42
312
  }
43
313
  /** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
44
314
  export async function atomicWriteText(path, content, options = {}) {
45
- const target = await writeTarget(path);
46
- const dir = dirname(target);
47
- await mkdir(dir, { recursive: true });
48
- let mode = 0o666;
315
+ if (options.boundary) {
316
+ if (resolve(path) !== resolve(options.boundary.target))
317
+ throw new FileChangedError(path);
318
+ verifyAtomicWriteBoundary(options.boundary);
319
+ }
320
+ let target = await writeTarget(path);
321
+ let dir = dirname(target);
322
+ const createdDirs = [];
323
+ await ensureDirectory(dir, createdDirs);
324
+ // Canonicalize the parent too, not just a final-component symlink. A workspace may sit beneath a linked
325
+ // directory; returning/rolling back the canonical target prevents a concurrent parent retarget from sending
326
+ // later transaction steps to a different tree.
327
+ dir = await realpath(dir);
328
+ target = join(dir, basename(target));
329
+ if (options.boundary) {
330
+ if (target !== options.boundary.target)
331
+ throw new FileChangedError(path);
332
+ verifyAtomicWriteBoundary(options.boundary);
333
+ }
334
+ const parentInfo = await lstat(dir);
335
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink())
336
+ throw new FileChangedError(dir);
337
+ const parentBoundary = {
338
+ target,
339
+ action: options.boundary?.action ?? "write",
340
+ ancestor: { path: dir, dev: parentInfo.dev, ino: parentInfo.ino },
341
+ };
342
+ const verifyCommitParent = () => {
343
+ verifyDirectoryIdentity(parentBoundary);
344
+ if (options.boundary)
345
+ verifyAtomicWriteBoundary(options.boundary);
346
+ };
347
+ verifyCommitParent();
348
+ // A caller-provided preflight identity is authoritative. Reading mode from a path before the move-claim
349
+ // would let a temporary same-path replacement influence the mode even when the expected inode is restored
350
+ // before claim verification.
351
+ let mode = options.mode === undefined ? (options.expectedIdentity?.mode ?? 0o666) : options.mode & 0o777;
352
+ let preserveExactMode = options.mode !== undefined || options.expectedIdentity !== undefined;
49
353
  try {
50
- mode = (await stat(target)).mode & 0o777;
354
+ const info = await stat(target);
355
+ if (!info.isFile())
356
+ throw new NonRegularFileError(path);
357
+ if (options.mode === undefined && !options.expectedIdentity)
358
+ mode = info.mode & 0o777;
359
+ preserveExactMode = true;
51
360
  }
52
361
  catch (error) {
53
362
  if (error?.code !== "ENOENT")
@@ -57,12 +366,23 @@ export async function atomicWriteText(path, content, options = {}) {
57
366
  // perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
58
367
  const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
59
368
  let staged = false;
369
+ let writtenIdentity;
370
+ const warnings = [];
371
+ let succeeded = false;
60
372
  try {
61
373
  const handle = await open(temp, "wx", mode);
62
374
  staged = true;
63
375
  try {
376
+ const opened = await handle.stat();
377
+ writtenIdentity = { dev: opened.dev, ino: opened.ino, mode: opened.mode & 0o777, nlink: opened.nlink };
64
378
  await handle.writeFile(content, "utf8");
379
+ // open(2) applies the process umask even when replacing an existing file. Restore that existing (or
380
+ // explicit rollback) mode on the staged fd; brand-new ordinary files still honor the user's umask.
381
+ if (preserveExactMode)
382
+ await handle.chmod(mode);
65
383
  await handle.sync();
384
+ const info = await handle.stat();
385
+ writtenIdentity = { dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
66
386
  }
67
387
  finally {
68
388
  await handle.close();
@@ -71,35 +391,127 @@ export async function atomicWriteText(path, content, options = {}) {
71
391
  // link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
72
392
  // appeared after validation, defeating create's no-clobber contract.
73
393
  try {
74
- await link(temp, target);
394
+ // Keep the final parent-identity check and namespace mutation in one JS turn. Node has no portable
395
+ // openat/linkat API; synchronous link is the narrowest available commit boundary.
396
+ verifyCommitParent();
397
+ linkSync(temp, target);
75
398
  }
76
399
  catch (error) {
77
400
  if (error?.code === "EEXIST")
78
401
  throw new FileChangedError(path);
79
402
  throw error;
80
403
  }
81
- await unlink(temp);
404
+ unlinkSync(temp);
82
405
  staged = false;
83
406
  }
84
- else {
85
- if (typeof options.expected === "string") {
86
- let current;
407
+ else if (typeof options.expected === "string") {
408
+ // Claim the directory entry BEFORE verification. Reading the old fd and then rename-overwriting the
409
+ // path leaves a verify→commit race; a concurrent replacement can otherwise be silently destroyed.
410
+ const claimed = join(dir, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
411
+ try {
412
+ verifyCommitParent();
413
+ renameSync(target, claimed);
414
+ }
415
+ catch (error) {
416
+ if (error?.code === "ENOENT")
417
+ throw new FileChangedError(path);
418
+ throw error;
419
+ }
420
+ let claimedIdentity;
421
+ try {
422
+ const pathInfo = await lstat(claimed);
423
+ claimedIdentity = {
424
+ dev: pathInfo.dev,
425
+ ino: pathInfo.ino,
426
+ mode: pathInfo.mode & 0o777,
427
+ nlink: pathInfo.nlink,
428
+ linkTarget: pathInfo.isSymbolicLink() ? readlinkSync(claimed) : undefined,
429
+ };
430
+ const current = await readRegularFileSnapshotNoFollow(claimed);
431
+ const expectedIdentity = options.expectedIdentity;
432
+ const identityMatches = !expectedIdentity ||
433
+ (current.dev === expectedIdentity.dev &&
434
+ current.ino === expectedIdentity.ino &&
435
+ current.mode === expectedIdentity.mode &&
436
+ current.nlink === expectedIdentity.nlink);
437
+ if (!identityMatches || current.text !== options.expected)
438
+ throw new FileChangedError(path);
439
+ }
440
+ catch (error) {
87
441
  try {
88
- current = await readFile(target, "utf8");
442
+ if (!claimedIdentity) {
443
+ const info = lstatSync(claimed);
444
+ claimedIdentity = {
445
+ dev: info.dev,
446
+ ino: info.ino,
447
+ mode: info.mode & 0o777,
448
+ nlink: info.nlink,
449
+ linkTarget: info.isSymbolicLink() ? readlinkSync(claimed) : undefined,
450
+ };
451
+ }
452
+ restoreClaimedPath(claimed, target, claimedIdentity);
89
453
  }
90
- catch {
91
- throw new FileChangedError(path);
454
+ catch (restoreError) {
455
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
92
456
  }
93
- if (current !== options.expected)
94
- throw new FileChangedError(path);
457
+ throw error;
458
+ }
459
+ if (!claimedIdentity)
460
+ throw new Error(`Failed to identify claimed file for ${path}`);
461
+ try {
462
+ verifyCommitParent();
463
+ linkSync(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
95
464
  }
96
- await rename(temp, target);
465
+ catch (error) {
466
+ let recovery = "";
467
+ try {
468
+ verifyCommitParent();
469
+ restoreClaimedPath(claimed, target, claimedIdentity);
470
+ }
471
+ catch (restoreError) {
472
+ recovery = `; claimed old entry is retained: ${restoreError?.message ?? String(restoreError)}`;
473
+ }
474
+ if (error?.code === "EEXIST")
475
+ throw new Error(`${new FileChangedError(path).message}${recovery}`);
476
+ throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
477
+ }
478
+ unlinkSync(temp);
479
+ staged = false;
480
+ try {
481
+ verifyCommitParent();
482
+ discardClaimedPath(claimed, claimedIdentity);
483
+ }
484
+ catch (error) {
485
+ warnings.push(`old entry cleanup was refused: ${error?.message ?? String(error)}`);
486
+ }
487
+ }
488
+ else {
489
+ verifyCommitParent();
490
+ renameSync(temp, target);
97
491
  staged = false;
98
492
  }
99
493
  await syncDirectory(dir);
494
+ succeeded = true;
100
495
  }
101
496
  finally {
102
- if (staged)
103
- await unlink(temp).catch(() => { });
497
+ if (staged && writtenIdentity) {
498
+ try {
499
+ verifyCommitParent();
500
+ const current = lstatSync(temp);
501
+ if (current.isFile()
502
+ && current.dev === writtenIdentity.dev
503
+ && current.ino === writtenIdentity.ino)
504
+ unlinkSync(temp);
505
+ }
506
+ catch {
507
+ // A changed parent makes path-based cleanup unsafe. Retaining an unpredictable private staging file
508
+ // is preferable to unlinking an entry supplied by a concurrent actor.
509
+ }
510
+ }
511
+ if (!succeeded)
512
+ await removeCreatedDirectories(createdDirs).catch(() => { });
104
513
  }
514
+ if (!writtenIdentity)
515
+ throw new Error(`Failed to identify staged file for ${path}`);
516
+ return { ...writtenIdentity, target, createdDirs, ...(warnings.length ? { warnings } : {}) };
105
517
  }
@@ -38,7 +38,10 @@ export function parseDingtalkMessage(msg) {
38
38
  text = flattenRichText(msg.content?.richText).trim();
39
39
  if (!text)
40
40
  return null; // unsupported type (audio/file/etc.) or empty
41
- return { msg: { chatId, userId, userName, text }, sessionWebhook };
41
+ // DingTalk documents conversationType=1 for a one-to-one bot chat and 2 for groups. Missing/novel values
42
+ // stay group-classified so protocol drift cannot expose the full-auto DM driver in a channel.
43
+ const chatType = String(msg.conversationType ?? "").trim() === "1" ? "p2p" : "group";
44
+ return { msg: { chatId, userId, userName, text, chatType }, sessionWebhook };
42
45
  }
43
46
  /** Flatten a DingTalk richText message (an array of {text}/{type} runs) into plain text (pure). */
44
47
  function flattenRichText(runs) {
@@ -4,9 +4,7 @@
4
4
  // the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
5
5
  // works unchanged. NOTE: receiving message text needs the privileged "Message Content Intent" enabled for the
6
6
  // bot in the Discord developer portal.
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
- import { join, basename } from "node:path";
9
- import { homedir } from "node:os";
7
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
10
8
  import { chunkText } from "./telegram.js";
11
9
  const REST = "https://discord.com/api/v10";
12
10
  const GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
@@ -20,16 +18,12 @@ const sleep = (ms, signal) => new Promise((r) => {
20
18
  signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
21
19
  });
22
20
  const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
23
- async function downloadDiscordAttachment(url, filename) {
21
+ async function downloadDiscordAttachment(url, filename, options) {
24
22
  try {
25
- const r = await fetch(url);
23
+ const r = await fetch(url, { signal: options.signal });
26
24
  if (!r.ok)
27
25
  return null;
28
- const dir = join(homedir(), ".hara", "discord", "media");
29
- mkdirSync(dir, { recursive: true });
30
- const path = join(dir, `dc_${Date.now()}_${basename(filename) || "file.bin"}`);
31
- writeFileSync(path, Buffer.from(await r.arrayBuffer()));
32
- return path;
26
+ return await savePrivateResponse(r, { platform: "discord", filenameHint: filename, ...options });
33
27
  }
34
28
  catch {
35
29
  return null;
@@ -37,7 +31,13 @@ async function downloadDiscordAttachment(url, filename) {
37
31
  }
38
32
  /** Parse a Discord MESSAGE_CREATE payload → InboundMsg + its image attachment URLs (pure; download happens in
39
33
  * start()). null = ignore (own message / another bot / empty). */
40
- export function parseDiscordMessage(d, selfId) {
34
+ export function discordChatType(message, resolvedChannelType) {
35
+ if (message?.guild_id)
36
+ return "group";
37
+ // Discord channel type 1 is a one-to-one DM; type 3 is a group DM. Missing/failed REST metadata stays group.
38
+ return Number(resolvedChannelType) === 1 ? "p2p" : "group";
39
+ }
40
+ export function parseDiscordMessage(d, selfId, resolvedChannelType) {
41
41
  if (!d?.channel_id || !d?.author?.id)
42
42
  return null;
43
43
  if (d.author.id === selfId || d.author.bot)
@@ -55,6 +55,7 @@ export function parseDiscordMessage(d, selfId) {
55
55
  userId: String(d.author.id),
56
56
  userName: d.author.global_name || d.author.username || String(d.author.id),
57
57
  text: text || "[图片]",
58
+ chatType: discordChatType(d, resolvedChannelType),
58
59
  },
59
60
  imageUrls,
60
61
  };
@@ -72,19 +73,19 @@ export function discordAdapter(token) {
72
73
  }).catch(() => { });
73
74
  }
74
75
  },
75
- async sendFile(chatId, filePath) {
76
+ async sendFile(chatId, file) {
76
77
  const form = new FormData();
77
78
  form.append("payload_json", JSON.stringify({}));
78
- form.append("files[0]", new Blob([readFileSync(filePath)]), basename(filePath));
79
+ form.append("files[0]", new Blob([new Uint8Array(file.bytes)]), file.safeName);
79
80
  await fetch(`${REST}/channels/${chatId}/messages`, { method: "POST", headers: auth, body: form }).catch(() => { });
80
81
  },
81
- async start(onMessage, signal) {
82
+ async start(onMessage, signal, shouldDownload) {
82
83
  if (!WSImpl) {
83
84
  console.error("hara gateway: Discord needs Node ≥ 22 (global WebSocket). Upgrade Node.");
84
85
  return;
85
86
  }
86
87
  while (!signal.aborted) {
87
- await connectOnce(token, onMessage, signal);
88
+ await connectOnce(token, onMessage, signal, shouldDownload);
88
89
  if (!signal.aborted)
89
90
  await sleep(3000, signal); // reconnect backoff
90
91
  }
@@ -93,9 +94,10 @@ export function discordAdapter(token) {
93
94
  }
94
95
  /** One gateway connection: HELLO→heartbeat, IDENTIFY, then dispatch MESSAGE_CREATE. Resolves on close/abort;
95
96
  * the caller reconnects. v1 keeps it simple — fresh IDENTIFY each time, no RESUME. */
96
- function connectOnce(token, onMessage, signal) {
97
+ function connectOnce(token, onMessage, signal, shouldDownload) {
97
98
  return new Promise((resolve) => {
98
99
  const ws = new WSImpl(GATEWAY);
100
+ const channelTypes = new Map();
99
101
  let hb = null;
100
102
  let seq = null;
101
103
  let selfId = "";
@@ -145,12 +147,43 @@ function connectOnce(token, onMessage, signal) {
145
147
  if (p.t === "READY")
146
148
  selfId = p.d?.user?.id ?? "";
147
149
  else if (p.t === "MESSAGE_CREATE") {
150
+ // Parse/filter first with the safe group default, then resolve non-guild channel metadata only for a
151
+ // real user message. Channel type is immutable, so known results are safe to cache for this connection.
148
152
  const parsed = parseDiscordMessage(p.d, selfId);
149
153
  if (parsed) {
150
- for (const im of parsed.imageUrls) {
151
- const path = await downloadDiscordAttachment(im.url, im.name);
152
- if (path)
153
- (parsed.msg.images ??= []).push(path);
154
+ if (!p.d?.guild_id) {
155
+ const channelId = String(p.d?.channel_id ?? "");
156
+ let channelType = channelTypes.get(channelId);
157
+ if (channelType === undefined && channelId) {
158
+ try {
159
+ const response = await fetch(`${REST}/channels/${encodeURIComponent(channelId)}`, {
160
+ headers: { Authorization: `Bot ${token}` },
161
+ signal,
162
+ });
163
+ const body = response.ok ? (await response.json()) : null;
164
+ const candidate = Number(body?.type);
165
+ if (Number.isSafeInteger(candidate)) {
166
+ channelType = candidate;
167
+ channelTypes.set(channelId, candidate);
168
+ if (channelTypes.size > 1000)
169
+ channelTypes.delete(channelTypes.keys().next().value);
170
+ }
171
+ }
172
+ catch {
173
+ /* cannot prove a DM → retain the safe group classification and retry on the next message */
174
+ }
175
+ }
176
+ parsed.msg.chatType = discordChatType(p.d, channelType);
177
+ }
178
+ if (shouldDownload?.(parsed.msg) === true) {
179
+ const budget = new InboundMediaBudget("discord", signal);
180
+ for (const im of parsed.imageUrls) {
181
+ const path = await budget.download((options) => downloadDiscordAttachment(im.url, im.name, options));
182
+ if (path) {
183
+ (parsed.msg.images ??= []).push(path);
184
+ (parsed.msg.transientFiles ??= []).push(path);
185
+ }
186
+ }
154
187
  }
155
188
  await onMessage(parsed.msg).catch(() => { });
156
189
  }