@nanhara/hara 0.121.0 → 0.122.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  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 +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -5,10 +5,11 @@
5
5
  // (iLink only uses crypto for media upload/download, which v1 doesn't do).
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
9
9
  import { basename } from "node:path";
10
10
  import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
11
11
  import { chunkText } from "./telegram.js";
12
+ import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
12
13
  const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
13
14
  const CHANNEL_VERSION = "2.2.0";
14
15
  const ILINK_APP_ID = "bot";
@@ -44,7 +45,6 @@ const WEIXIN_CDN_ALLOWLIST = new Set([
44
45
  "mmbiz.qlogo.cn",
45
46
  ]);
46
47
  const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
47
- const INBOUND_MEDIA_MAX = 128 * 1024 * 1024; // 128 MiB cap, per the reference
48
48
  const num = (v) => (typeof v === "number" ? v : 0);
49
49
  const str = (v) => (v == null ? "" : String(v));
50
50
  // ── pure protocol helpers (unit-tested) ──────────────────────────────────────
@@ -223,40 +223,74 @@ function saveCursor(accountId, buf) {
223
223
  // Per-peer context_token: every reply must echo the latest token iLink sent for that peer.
224
224
  class TokenStore {
225
225
  accountId;
226
- cache = {};
226
+ cache = new Map();
227
227
  constructor(accountId) {
228
228
  this.accountId = accountId;
229
229
  try {
230
- if (existsSync(this.file()))
231
- this.cache = JSON.parse(readFileSync(this.file(), "utf8"));
230
+ const exists = existsSync(this.file());
231
+ const parsed = exists ? JSON.parse(readFileSync(this.file(), "utf8")) : {};
232
+ if (exists)
233
+ chmodSync(this.file(), 0o600);
234
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
235
+ for (const [peer, token] of Object.entries(parsed).slice(-1000)) {
236
+ if (peer && typeof token === "string" && token)
237
+ this.cache.set(peer, token);
238
+ }
239
+ }
232
240
  }
233
241
  catch {
234
- this.cache = {};
242
+ this.cache.clear();
235
243
  }
236
244
  }
237
245
  file() {
238
246
  return join(weixinDir(), `${this.accountId}.context-tokens.json`);
239
247
  }
248
+ peerKey(peer) {
249
+ return String(peer ?? "").trim().slice(0, 256);
250
+ }
240
251
  persist() {
252
+ const temporary = `${this.file()}.${process.pid}.${randomUUID()}.tmp`;
241
253
  try {
242
- mkdirSync(weixinDir(), { recursive: true });
243
- writeFileSync(this.file(), JSON.stringify(this.cache));
254
+ mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
255
+ chmodSync(weixinDir(), 0o700);
256
+ writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cache)), { mode: 0o600, flag: "wx" });
257
+ renameSync(temporary, this.file());
258
+ chmodSync(this.file(), 0o600);
244
259
  }
245
260
  catch {
246
261
  /* best-effort */
247
262
  }
263
+ finally {
264
+ try {
265
+ rmSync(temporary, { force: true });
266
+ }
267
+ catch {
268
+ /* best-effort */
269
+ }
270
+ }
248
271
  }
249
272
  get(peer) {
250
- return this.cache[peer] || undefined;
273
+ const key = this.peerKey(peer);
274
+ const token = this.cache.get(key);
275
+ if (token) {
276
+ this.cache.delete(key);
277
+ this.cache.set(key, token);
278
+ }
279
+ return token;
251
280
  }
252
281
  set(peer, token) {
253
- if (token) {
254
- this.cache[peer] = token;
282
+ const key = this.peerKey(peer);
283
+ const boundedToken = String(token ?? "").slice(0, 4096);
284
+ if (key && boundedToken) {
285
+ this.cache.delete(key);
286
+ this.cache.set(key, boundedToken);
287
+ while (this.cache.size > 1000)
288
+ this.cache.delete(this.cache.keys().next().value);
255
289
  this.persist();
256
290
  }
257
291
  }
258
292
  del(peer) {
259
- delete this.cache[peer];
293
+ this.cache.delete(this.peerKey(peer));
260
294
  this.persist();
261
295
  }
262
296
  }
@@ -333,14 +367,25 @@ async function sendChunk(creds, tokenStore, peer, text) {
333
367
  const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
334
368
  const post = (ctx) => apiPost(creds.base_url, EP.sendMessage, buildSendBody(peer, text, ctx, clientId), creds.token, API_TIMEOUT_MS).catch((e) => ({ ret: 1, errmsg: String(e?.message ?? e) }));
335
369
  let resp = await post(tokenStore.get(peer));
370
+ // HARA_WX_DEBUG=1 → dump the raw send response (protocol exploration: does iLink return a message id we
371
+ // could use for a future recall/revoke?). Off by default; stderr only.
372
+ if (process.env.HARA_WX_DEBUG === "1")
373
+ console.error("weixin send resp:", JSON.stringify(resp).slice(0, 500));
336
374
  let [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
337
375
  if (isSessionExpired(ret, errcode, errmsg)) {
338
376
  tokenStore.del(peer); // stale → drop it and retry once tokenless (iLink accepts that, degraded)
339
377
  resp = await post(undefined);
340
378
  [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
341
379
  }
380
+ // Rate-limit (ret=-2, empty errmsg): iLink throttles cold/rapid proactive pushes. Back off and retry a few
381
+ // times instead of silently dropping the message — the reused clientId dedups so a retry can't double-send.
382
+ for (let attempt = 0; ret === RATE_LIMIT && errcode === 0 && attempt < 3; attempt++) {
383
+ await sleep(1500 * (attempt + 1));
384
+ resp = await post(tokenStore.get(peer));
385
+ [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
386
+ }
342
387
  if (ret !== 0 || errcode !== 0)
343
- console.error(`weixin send: ret=${ret} errcode=${errcode} errmsg=${errmsg}`);
388
+ throw new Error(`weixin send failed: ret=${ret} errcode=${errcode} errmsg=${errmsg || "unknown error"}`);
344
389
  }
345
390
  function aes128EcbEncrypt(plaintext, key) {
346
391
  const c = createCipheriv("aes-128-ecb", key, null); // PKCS7 auto-padding (Node default) matches iLink
@@ -397,8 +442,6 @@ export function inboundMediaRefs(itemList) {
397
442
  return out;
398
443
  }
399
444
  const cdnDownloadUrl = (param) => `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(param)}`;
400
- const mediaDir = () => join(weixinDir(), "media");
401
- const sanitizeName = (name) => name.replace(/[^\w.\- ]+/g, "_").slice(-80) || "file.bin";
402
445
  function assertCdnHost(url) {
403
446
  let u;
404
447
  try {
@@ -413,7 +456,10 @@ function assertCdnHost(url) {
413
456
  throw new Error(`host ${u.hostname} not in WeChat CDN allowlist (SSRF guard)`);
414
457
  }
415
458
  /** Download (+ AES-decrypt if keyed) an inbound media item to a local file. Returns its path + mime, or null. */
416
- export async function downloadInboundMedia(ref) {
459
+ export async function downloadInboundMedia(ref, options = {
460
+ maxBytes: INBOUND_MEDIA_MAX_BYTES,
461
+ signal: AbortSignal.timeout(INBOUND_MEDIA_TIMEOUT_MS),
462
+ }) {
417
463
  try {
418
464
  let url;
419
465
  if (ref.encryptQueryParam)
@@ -424,22 +470,20 @@ export async function downloadInboundMedia(ref) {
424
470
  }
425
471
  else
426
472
  return null;
427
- const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
473
+ const res = await fetch(url, { signal: options.signal });
428
474
  if (!res.ok)
429
475
  throw new Error(`media download HTTP ${res.status}`);
430
- const ab = await res.arrayBuffer();
431
- if (ab.byteLength > INBOUND_MEDIA_MAX)
432
- throw new Error("media exceeds size cap");
433
- let buf = Buffer.from(ab);
476
+ let buf = await readResponseBytesLimited(res, options.maxBytes, options.signal);
434
477
  if (ref.aesKeyB64)
435
478
  buf = aes128EcbDecrypt(buf, parseAesKey(ref.aesKeyB64));
436
479
  if (!buf.length)
437
480
  return null;
438
- mkdirSync(mediaDir(), { recursive: true });
439
- const tag = randomUUID().slice(0, 8);
440
- const name = ref.kind === "image" ? `img_${tag}.jpg` : ref.kind === "voice" ? `audio_${tag}.silk` : `${tag}_${sanitizeName(ref.fileName || "document.bin")}`;
441
- const path = join(mediaDir(), name);
442
- writeFileSync(path, buf);
481
+ const filenameHint = ref.kind === "image" ? "image.jpg" : ref.kind === "voice" ? "audio.silk" : ref.fileName || "document.bin";
482
+ const path = await savePrivateMediaBytes(buf, {
483
+ platform: "weixin",
484
+ filenameHint,
485
+ ...options,
486
+ });
443
487
  return { path, mime: ref.kind === "image" ? "image/jpeg" : ref.kind === "voice" ? "audio/silk" : "application/octet-stream" };
444
488
  }
445
489
  catch (e) {
@@ -510,34 +554,67 @@ export function weixinAdapter(creds) {
510
554
  const tokenStore = new TokenStore(creds.account_id);
511
555
  // Reuse parseWeixinMessage for text/voice-transcription, then download any image/file/voice media and append
512
556
  // a `[kind: localpath]` reference so hara can read the file. Handles media-only messages (no text) too.
513
- const buildInbound = async (msg) => {
557
+ const buildInbound = async (msg, signal, shouldDownload) => {
514
558
  const parsed = parseWeixinMessage(msg, creds.account_id);
515
559
  const from = str(msg?.from_user_id).trim();
516
560
  if (!from || from === creds.account_id)
517
561
  return null;
518
- if (guessChatType(msg, creds.account_id).kind !== "dm")
562
+ const chat = guessChatType(msg, creds.account_id);
563
+ const refs = inboundMediaRefs(msg?.item_list);
564
+ let text = parsed?.inbound.text ?? extractText(msg?.item_list);
565
+ const base = {
566
+ chatId: chat.id,
567
+ userId: from,
568
+ userName: from,
569
+ text: text || (refs.some((ref) => ref.kind === "image") ? "[图片]" : refs.length ? "[附件]" : ""),
570
+ chatType: chat.kind === "dm" ? "p2p" : "group",
571
+ };
572
+ if (!base.text)
519
573
  return null;
520
- let text = parsed?.inbound.text ?? "";
574
+ const downloadAllowed = base.chatType === "p2p" && shouldDownload?.(base) === true;
575
+ // Persist context only for an authenticated DM, before any download. Rejected senders cannot grow state.
576
+ if (downloadAllowed)
577
+ tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
521
578
  const images = [];
522
- for (const ref of inboundMediaRefs(msg?.item_list)) {
523
- const dl = await downloadInboundMedia(ref);
524
- if (!dl)
525
- continue;
526
- if (ref.kind === "image") {
527
- // images go through as real attachments (seen/described downstream); leave only a light marker in text
528
- images.push(dl.path);
529
- text += `${text ? "\n" : ""}[图片]`;
530
- }
531
- else {
532
- const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
533
- text += `${text ? "\n" : ""}[${label}: ${dl.path}]`;
579
+ const transientFiles = [];
580
+ let handedOff = false;
581
+ try {
582
+ if (downloadAllowed) {
583
+ const budget = new InboundMediaBudget("weixin", signal);
584
+ for (const ref of refs) {
585
+ let downloaded = null;
586
+ const path = await budget.download(async (options) => {
587
+ downloaded = await downloadInboundMedia(ref, options);
588
+ return downloaded?.path ?? null;
589
+ });
590
+ if (!path || !downloaded)
591
+ continue;
592
+ transientFiles.push(path);
593
+ if (ref.kind === "image") {
594
+ // images go through as real attachments (seen/described downstream); leave only a light marker in text
595
+ images.push(path);
596
+ if (text !== "[图片]")
597
+ text += `${text ? "\n" : ""}[图片]`;
598
+ }
599
+ else {
600
+ const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
601
+ text = text === "[附件]" ? `[${label}: ${path}]` : `${text}${text ? "\n" : ""}[${label}: ${path}]`;
602
+ }
603
+ }
534
604
  }
605
+ const inbound = {
606
+ ...base,
607
+ text: text.trim() || base.text,
608
+ images: images.length ? images : undefined,
609
+ transientFiles: transientFiles.length ? transientFiles : undefined,
610
+ };
611
+ handedOff = true;
612
+ return inbound;
613
+ }
614
+ finally {
615
+ if (!handedOff && transientFiles.length)
616
+ await cleanupTransientMedia("weixin", transientFiles);
535
617
  }
536
- text = text.trim();
537
- if (!text && !images.length)
538
- return null;
539
- tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
540
- return { chatId: from, userId: from, userName: from, text: text || "[图片]", images: images.length ? images : undefined };
541
618
  };
542
619
  return {
543
620
  name: "weixin",
@@ -547,9 +624,10 @@ export function weixinAdapter(creds) {
547
624
  await sendChunk(creds, tokenStore, peer, part);
548
625
  },
549
626
  async sendFile(chatId, filePath) {
550
- await sendMediaFile(creds, tokenStore, String(chatId), filePath);
627
+ if (!(await sendMediaFile(creds, tokenStore, String(chatId), filePath)))
628
+ throw new Error(`weixin file delivery failed: ${filePath}`);
551
629
  },
552
- async start(onMessage, signal) {
630
+ async start(onMessage, signal, shouldDownload) {
553
631
  let buf = loadCursor(creds.account_id);
554
632
  let pollMs = LONG_POLL_TIMEOUT_MS;
555
633
  while (!signal.aborted) {
@@ -578,7 +656,7 @@ export function weixinAdapter(creds) {
578
656
  buf = str(resp.get_updates_buf) || buf;
579
657
  saveCursor(creds.account_id, buf);
580
658
  for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
581
- const inbound = await buildInbound(msg);
659
+ const inbound = await buildInbound(msg, signal, shouldDownload);
582
660
  if (inbound)
583
661
  await onMessage(inbound).catch(() => { });
584
662
  }
package/dist/hooks.js CHANGED
@@ -1,24 +1,28 @@
1
1
  // Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
2
- // PreToolUse runs BEFORE a tool: a non-zero exit BLOCKS the call (its output becomes the denial message).
2
+ // PreToolUse runs BEFORE a tool: non-zero, timeout, signal, or launch failure BLOCKS the call.
3
3
  // PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
4
4
  // by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
5
5
  import { spawnSync } from "node:child_process";
6
+ import { resolve } from "node:path";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { pluginHooks } from "./plugins/plugins.js";
8
- let cache = null;
9
+ const cache = new Map();
9
10
  export function resetHooksCache() {
10
- cache = null;
11
+ cache.clear();
11
12
  }
12
- function merged() {
13
- if (cache)
14
- return cache;
15
- const cfg = loadConfig().hooks ?? {};
13
+ function merged(cwd) {
14
+ const key = resolve(cwd);
15
+ const cached = cache.get(key);
16
+ if (cached)
17
+ return cached;
18
+ const cfg = loadConfig({ cwd: key }).hooks ?? {};
16
19
  const plg = pluginHooks();
17
- cache = {
20
+ const value = {
18
21
  PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
19
22
  PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
20
23
  };
21
- return cache;
24
+ cache.set(key, value);
25
+ return value;
22
26
  }
23
27
  const matches = (m, name) => {
24
28
  if (!m || m === "*")
@@ -31,14 +35,14 @@ const matches = (m, name) => {
31
35
  }
32
36
  };
33
37
  /** True if any hook is configured (lets the loop skip the work entirely in the common case). */
34
- export function hasHooks() {
35
- const h = merged();
38
+ export function hasHooks(cwd = process.cwd()) {
39
+ const h = merged(cwd);
36
40
  return !!(h.PreToolUse?.length || h.PostToolUse?.length);
37
41
  }
38
- /** Run hooks for an event matching `toolName`. PreToolUse: a non-zero exit BLOCKS (returns the message);
39
- * PostToolUse: observe-only, never blocks. Sync (hooks are short, opt-in); 30s timeout each. */
40
- export function runHooks(event, toolName, payload, cwd) {
41
- for (const h of merged()[event] ?? []) {
42
+ /** Run hooks for an event matching `toolName`. Any abnormal PreToolUse termination fails closed;
43
+ * PostToolUse remains observe-only. Sync (hooks are short, opt-in); 30s timeout each. */
44
+ export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
45
+ for (const h of merged(cwd)[event] ?? []) {
42
46
  if (!matches(h.matcher, toolName))
43
47
  continue;
44
48
  let r;
@@ -48,16 +52,24 @@ export function runHooks(event, toolName, payload, cwd) {
48
52
  cwd,
49
53
  input: JSON.stringify({ tool: toolName, payload }),
50
54
  encoding: "utf8",
51
- timeout: 30_000,
55
+ timeout: timeoutMs,
52
56
  env: { ...process.env, HARA_TOOL_NAME: toolName },
53
57
  });
54
58
  }
55
- catch {
59
+ catch (error) {
60
+ if (event === "PreToolUse") {
61
+ const detail = error instanceof Error ? error.message : String(error);
62
+ return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
63
+ }
56
64
  continue;
57
65
  }
58
- if (event === "PreToolUse" && r.status !== 0 && r.status !== null) {
59
- const msg = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
60
- return { block: true, message: `⛔ blocked by a PreToolUse hook${msg ? `: ${msg}` : ` (exit ${r.status})`}` };
66
+ // A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
67
+ // writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
68
+ // that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
69
+ if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
70
+ const output = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
71
+ const failure = r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`);
72
+ return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
61
73
  }
62
74
  }
63
75
  return { block: false, message: "" };