@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
@@ -11,11 +11,11 @@
11
11
  // EXTERNAL DEPENDENCY: signal-cli is NOT bundled — it's a separate program the user installs and runs (it is the
12
12
  // only way to speak Signal's protocol; there is no official cloud API). The adapter just speaks HTTP/JSON-RPC to
13
13
  // the daemon the user has running. See setup notes at the bottom of this file.
14
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
- import { join, basename } from "node:path";
16
- import { homedir } from "node:os";
17
14
  import { chunkText } from "./telegram.js";
15
+ import { INBOUND_MEDIA_MAX_BYTES, InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
18
16
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ // Base64 expands by 4/3; leave a small fixed budget for the JSON-RPC envelope without permitting an unlimited body.
18
+ const ATTACHMENT_RPC_MAX_BYTES = Math.ceil(INBOUND_MEDIA_MAX_BYTES / 3) * 4 + 64 * 1024;
19
19
  // E.164 phone numbers (+15551234567) anywhere in a string → +155****4567 (mirrors hermes' _redact_phone).
20
20
  const PHONE_RE = /\+[1-9]\d{6,14}/g;
21
21
  /** Redact phone numbers for logging. Keeps a 4/4 head+tail window; never logs the full E.164. (pure) */
@@ -85,7 +85,10 @@ export function parseSignalMessage(raw, selfNumber) {
85
85
  if (!data || typeof data !== "object")
86
86
  return null;
87
87
  const group = data.groupInfo;
88
- const groupId = group?.groupId;
88
+ // signal-cli omits (or nulls) groupInfo for a direct envelope. A present non-null but malformed group object
89
+ // remains group-classified even without an id, which is the conservative behavior during protocol drift.
90
+ const isGroup = group != null;
91
+ const groupId = typeof group?.groupId === "string" && group.groupId.trim() ? group.groupId.trim() : undefined;
89
92
  const chatId = groupId ? `group:${groupId}` : String(sender);
90
93
  const atts = Array.isArray(data.attachments) ? data.attachments : [];
91
94
  const images = atts
@@ -100,6 +103,7 @@ export function parseSignalMessage(raw, selfNumber) {
100
103
  userId: String(sender),
101
104
  userName: env.sourceName || String(sender),
102
105
  text: text || "[图片]",
106
+ chatType: isGroup ? "group" : "p2p",
103
107
  },
104
108
  images,
105
109
  };
@@ -108,7 +112,7 @@ export function signalAdapter(rpcUrl, selfNumber) {
108
112
  const base = rpcUrl.replace(/\/+$/, ""); // trim trailing slashes
109
113
  let rpcSeq = 0;
110
114
  /** One JSON-RPC 2.0 call to the signal-cli daemon. Returns `result` (any) or null on error. */
111
- async function rpc(method, params, signal) {
115
+ async function rpc(method, params, signal, responseLimit) {
112
116
  const id = `${method}_${++rpcSeq}`;
113
117
  try {
114
118
  const res = await fetch(`${base}/api/v1/rpc`, {
@@ -119,7 +123,9 @@ export function signalAdapter(rpcUrl, selfNumber) {
119
123
  });
120
124
  if (!res.ok)
121
125
  return null;
122
- const j = (await res.json());
126
+ const j = (responseLimit
127
+ ? JSON.parse((await readResponseBytesLimited(res, responseLimit, signal)).toString("utf8"))
128
+ : await res.json());
123
129
  if (j.error) {
124
130
  console.error(`hara gateway[signal]: RPC ${method} error:`, redactPhone(JSON.stringify(j.error)));
125
131
  return null;
@@ -132,28 +138,25 @@ export function signalAdapter(rpcUrl, selfNumber) {
132
138
  return null;
133
139
  }
134
140
  }
135
- /** Recipient/group routing shared by send + sendFile (mirrors hermes). */
141
+ /** Recipient/group routing for outbound text sends (mirrors hermes). */
136
142
  const target = (chatId) => {
137
143
  const id = String(chatId);
138
144
  return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
139
145
  };
140
146
  /** Fetch a signal-cli attachment by id (base64 over JSON-RPC) → a local path under ~/.hara/signal/media. */
141
- async function downloadAttachment(ref) {
147
+ async function downloadAttachment(ref, options) {
142
148
  try {
143
- const result = await rpc("getAttachment", { account: selfNumber, id: ref.id });
149
+ const encodedLimit = Math.ceil(options.maxBytes / 3) * 4 + 64 * 1024;
150
+ const result = await rpc("getAttachment", { account: selfNumber, id: ref.id }, options.signal, Math.min(encodedLimit, ATTACHMENT_RPC_MAX_BYTES));
144
151
  // signal-cli returns either a raw base64 string or { data: "base64..." }
145
152
  const b64 = typeof result === "string" ? result : result && typeof result === "object" ? result.data : null;
146
153
  if (!b64 || typeof b64 !== "string")
147
154
  return null;
148
- const bytes = Buffer.from(b64, "base64");
155
+ const bytes = decodeBase64Media(b64, options.maxBytes);
149
156
  let ext = attachmentExt(ref.contentType, ref.filename);
150
157
  if (ext === ".bin")
151
158
  ext = extFromBytes(bytes); // last-resort magic-byte sniff
152
- const dir = join(homedir(), ".hara", "signal", "media");
153
- mkdirSync(dir, { recursive: true });
154
- const path = join(dir, `sig_${Date.now()}_${ref.id.slice(-12)}${ext}`);
155
- writeFileSync(path, bytes);
156
- return path;
159
+ return await savePrivateMediaBytes(bytes, { platform: "signal", filenameHint: `attachment${ext}`, ...options });
157
160
  }
158
161
  catch {
159
162
  return null;
@@ -166,15 +169,10 @@ export function signalAdapter(rpcUrl, selfNumber) {
166
169
  await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
167
170
  }
168
171
  },
169
- async sendFile(chatId, filePath) {
170
- // Signal has no separate photo/document endpoints everything is an `attachments` path on `send`.
171
- // signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
172
- await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
173
- // (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
174
- void readFileSync;
175
- void basename;
176
- },
177
- async start(onMessage, signal) {
172
+ // signal-cli's JSON-RPC attachment API accepts only a server-side pathname. Exposing sendFile here would
173
+ // reopen the verified snapshot after the security boundary, so outbound files stay disabled for Signal
174
+ // until its daemon offers a byte-oriented upload API.
175
+ async start(onMessage, signal, shouldDownload) {
178
176
  console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
179
177
  while (!signal.aborted) {
180
178
  try {
@@ -186,10 +184,15 @@ export function signalAdapter(rpcUrl, selfNumber) {
186
184
  const parsed = parseSignalMessage(raw, selfNumber);
187
185
  if (!parsed)
188
186
  continue;
189
- for (const ref of parsed.images) {
190
- const path = await downloadAttachment(ref);
191
- if (path)
192
- (parsed.msg.images ??= []).push(path);
187
+ if (shouldDownload?.(parsed.msg) === true) {
188
+ const budget = new InboundMediaBudget("signal", signal);
189
+ for (const ref of parsed.images) {
190
+ const path = await budget.download((options) => downloadAttachment(ref, options));
191
+ if (path) {
192
+ (parsed.msg.images ??= []).push(path);
193
+ (parsed.msg.transientFiles ??= []).push(path);
194
+ }
195
+ }
193
196
  }
194
197
  await onMessage(parsed.msg).catch(() => { });
195
198
  }
@@ -4,9 +4,7 @@
4
4
  // API (bot token, xoxb-). Same ChatAdapter shape as Discord/Telegram, so all cross-platform gateway plumbing
5
5
  // (send_file, in-chat system context, stuck-guard, image attach/describe) works unchanged. Two tokens are
6
6
  // required because Socket Mode (xapp-) and the Web API (xoxb-) are separate auth scopes.
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 WEB = "https://slack.com/api";
12
10
  const WSImpl = globalThis.WebSocket;
@@ -34,16 +32,12 @@ async function slackApi(method, token, body) {
34
32
  }
35
33
  /** Slack file url_private is gated — it needs the BOT token as a Bearer header (a plain GET returns the login
36
34
  * page HTML, not the bytes). Download into ~/.hara/slack/media so the agent can SEE it. */
37
- async function downloadSlackFile(url, name, botToken) {
35
+ async function downloadSlackFile(url, name, botToken, options) {
38
36
  try {
39
- const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } });
37
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` }, signal: options.signal });
40
38
  if (!r.ok)
41
39
  return null;
42
- const dir = join(homedir(), ".hara", "slack", "media");
43
- mkdirSync(dir, { recursive: true });
44
- const path = join(dir, `sl_${Date.now()}_${basename(name) || "file.bin"}`);
45
- writeFileSync(path, Buffer.from(await r.arrayBuffer()));
46
- return path;
40
+ return await savePrivateResponse(r, { platform: "slack", filenameHint: name, ...options });
47
41
  }
48
42
  catch {
49
43
  return null;
@@ -52,6 +46,13 @@ async function downloadSlackFile(url, name, botToken) {
52
46
  /** Parse a Slack Events API `event` (the inner `payload.event`) → InboundMsg + its image file refs (pure;
53
47
  * download happens in start()). null = ignore (not a user message / our own / an edit/delete subtype / empty).
54
48
  * Mirrors discord.ts's parseDiscordMessage so it's unit-testable without the network. */
49
+ export function slackChatType(event) {
50
+ const channel = String(event?.channel ?? "").trim();
51
+ // Slack reserves D-prefixed ids for direct-message channels. It is stronger than a contradictory event field.
52
+ if (channel.startsWith("D"))
53
+ return "p2p";
54
+ return String(event?.channel_type ?? "").trim().toLowerCase() === "im" ? "p2p" : "group";
55
+ }
55
56
  export function parseSlackEvent(event, selfId) {
56
57
  if (event?.type !== "message")
57
58
  return null;
@@ -76,6 +77,7 @@ export function parseSlackEvent(event, selfId) {
76
77
  userId: String(event.user),
77
78
  userName: String(event.user), // Slack events carry only the id; users.info would resolve a name (skipped for zero extra calls)
78
79
  text: text || "[图片]",
80
+ chatType: slackChatType(event),
79
81
  },
80
82
  imageUrls,
81
83
  };
@@ -89,16 +91,16 @@ export function slackAdapter(appToken, botToken) {
89
91
  await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
90
92
  }
91
93
  },
92
- async sendFile(chatId, filePath) {
94
+ async sendFile(chatId, file) {
93
95
  // 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
94
96
  // non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
95
97
  try {
96
- const bytes = readFileSync(filePath);
97
- const name = basename(filePath);
98
+ const bytes = file.bytes;
99
+ const name = file.safeName;
98
100
  const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
99
101
  if (!url?.ok || !url.upload_url || !url.file_id)
100
102
  return;
101
- const up = await fetch(url.upload_url, { method: "POST", body: new Blob([bytes]) }); // presigned URL: no auth header
103
+ const up = await fetch(url.upload_url, { method: "POST", body: new Blob([new Uint8Array(bytes)]) }); // presigned URL: no auth header
102
104
  if (!up.ok)
103
105
  return;
104
106
  await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
@@ -107,7 +109,7 @@ export function slackAdapter(appToken, botToken) {
107
109
  /* upload/send failed — surfaced upstream as "no file delivered" */
108
110
  }
109
111
  },
110
- async start(onMessage, signal) {
112
+ async start(onMessage, signal, shouldDownload) {
111
113
  if (!WSImpl) {
112
114
  console.error("hara gateway: Slack needs Node ≥ 22 (global WebSocket). Upgrade Node.");
113
115
  return;
@@ -118,7 +120,7 @@ export function slackAdapter(appToken, botToken) {
118
120
  while (!signal.aborted) {
119
121
  const wss = await openSocketUrl(appToken);
120
122
  if (wss)
121
- await connectOnce(wss, botToken, selfId, onMessage, signal);
123
+ await connectOnce(wss, botToken, selfId, onMessage, signal, shouldDownload);
122
124
  if (!signal.aborted)
123
125
  await sleep(3000, signal); // reconnect backoff (and re-open: each URL is single-use)
124
126
  }
@@ -133,7 +135,7 @@ async function openSocketUrl(appToken) {
133
135
  /** One Socket Mode connection: receive envelopes, ACK each by echoing its envelope_id, and dispatch message
134
136
  * events. Resolves on close/disconnect/abort; the caller re-opens a new URL and reconnects. v1 keeps it simple —
135
137
  * no buffered-payload backpressure (the daemon spawns one hara per message anyway). */
136
- function connectOnce(url, botToken, selfId, onMessage, signal) {
138
+ function connectOnce(url, botToken, selfId, onMessage, signal, shouldDownload) {
137
139
  return new Promise((resolve) => {
138
140
  const ws = new WSImpl(url);
139
141
  const stop = () => {
@@ -176,10 +178,15 @@ function connectOnce(url, botToken, selfId, onMessage, signal) {
176
178
  if (p.type === "events_api") {
177
179
  const parsed = parseSlackEvent(p.payload?.event, selfId);
178
180
  if (parsed) {
179
- for (const im of parsed.imageUrls) {
180
- const path = await downloadSlackFile(im.url, im.name, botToken);
181
- if (path)
182
- (parsed.msg.images ??= []).push(path);
181
+ if (shouldDownload?.(parsed.msg) === true) {
182
+ const budget = new InboundMediaBudget("slack", signal);
183
+ for (const im of parsed.imageUrls) {
184
+ const path = await budget.download((options) => downloadSlackFile(im.url, im.name, botToken, options));
185
+ if (path) {
186
+ (parsed.msg.images ??= []).push(path);
187
+ (parsed.msg.transientFiles ??= []).push(path);
188
+ }
189
+ }
183
190
  }
184
191
  await onMessage(parsed.msg).catch(() => { });
185
192
  }
@@ -1,9 +1,7 @@
1
1
  // Telegram adapter for `hara gateway` — long-poll getUpdates + sendMessage over the Bot API (built-in
2
2
  // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
3
  // / Feishu plug into next.
4
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
- import { join, basename } from "node:path";
6
- import { homedir } from "node:os";
4
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
7
5
  const API = "https://api.telegram.org";
8
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
7
  /** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
@@ -21,6 +19,7 @@ export function parseTelegramUpdate(u) {
21
19
  userId: m.from?.id ?? 0,
22
20
  userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
23
21
  text: text || "[图片]",
22
+ ...(m.chat?.type === "private" ? { chatType: "p2p" } : ["group", "supergroup"].includes(m.chat?.type) ? { chatType: "group" } : {}),
24
23
  };
25
24
  }
26
25
  /** The largest photo's file_id (Telegram sends `photo` as an ascending size array) (pure). null if none. */
@@ -29,21 +28,17 @@ export function photoFileId(u) {
29
28
  return Array.isArray(photo) && photo.length ? (photo[photo.length - 1]?.file_id ?? null) : null;
30
29
  }
31
30
  /** Download a Telegram file by file_id → a local path under ~/.hara/telegram/media (getFile then the file CDN). */
32
- async function downloadTelegramFile(base, token, fileId) {
31
+ async function downloadTelegramFile(base, token, fileId, options) {
33
32
  try {
34
- const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
33
+ const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`, { signal: options.signal });
35
34
  const j = (await r.json());
36
35
  const fp = j?.result?.file_path;
37
36
  if (!fp)
38
37
  return null;
39
- const dl = await fetch(`${API}/file/bot${token}/${fp}`);
38
+ const dl = await fetch(`${API}/file/bot${token}/${fp}`, { signal: options.signal });
40
39
  if (!dl.ok)
41
40
  return null;
42
- const dir = join(homedir(), ".hara", "telegram", "media");
43
- mkdirSync(dir, { recursive: true });
44
- const path = join(dir, `tg_${Date.now()}_${basename(fp)}`);
45
- writeFileSync(path, Buffer.from(await dl.arrayBuffer()));
46
- return path;
41
+ return await savePrivateResponse(dl, { platform: "telegram", filenameHint: fp, ...options });
47
42
  }
48
43
  catch {
49
44
  return null;
@@ -60,27 +55,41 @@ export function chunkText(text, max = 4000) {
60
55
  }
61
56
  export function telegramAdapter(token) {
62
57
  const base = `${API}/bot${token}`;
58
+ const request = async (method, init) => {
59
+ const response = await fetch(`${base}/${method}`, init);
60
+ let body;
61
+ try {
62
+ body = await response.json();
63
+ }
64
+ catch {
65
+ body = undefined;
66
+ }
67
+ if (!response.ok || body?.ok === false) {
68
+ throw new Error(`Telegram ${method} failed: HTTP ${response.status}${body?.description ? ` · ${body.description}` : ""}`);
69
+ }
70
+ return body;
71
+ };
63
72
  return {
64
73
  name: "telegram",
65
74
  async send(chatId, text) {
66
75
  for (const part of chunkText(text || "(empty)")) {
67
- await fetch(`${base}/sendMessage`, {
76
+ await request("sendMessage", {
68
77
  method: "POST",
69
78
  headers: { "content-type": "application/json" },
70
79
  body: JSON.stringify({ chat_id: chatId, text: part }),
71
- }).catch(() => { });
80
+ });
72
81
  }
73
82
  },
74
- async sendFile(chatId, filePath) {
83
+ async sendFile(chatId, file) {
75
84
  // images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
76
- const name = basename(filePath);
85
+ const name = file.safeName;
77
86
  const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
78
87
  const form = new FormData();
79
88
  form.append("chat_id", String(chatId));
80
- form.append(isImg ? "photo" : "document", new Blob([readFileSync(filePath)]), name);
81
- await fetch(`${base}/${isImg ? "sendPhoto" : "sendDocument"}`, { method: "POST", body: form }).catch(() => { });
89
+ form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
90
+ await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
82
91
  },
83
- async start(onMessage, signal) {
92
+ async start(onMessage, signal, shouldDownload) {
84
93
  let offset = 0;
85
94
  while (!signal.aborted) {
86
95
  try {
@@ -96,10 +105,13 @@ export function telegramAdapter(token) {
96
105
  if (!msg)
97
106
  continue;
98
107
  const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
99
- if (fid) {
100
- const path = await downloadTelegramFile(base, token, fid);
101
- if (path)
108
+ if (fid && shouldDownload?.(msg) === true) {
109
+ const budget = new InboundMediaBudget("telegram", signal);
110
+ const path = await budget.download((options) => downloadTelegramFile(base, token, fid, options));
111
+ if (path) {
102
112
  msg.images = [path];
113
+ msg.transientFiles = [path];
114
+ }
103
115
  }
104
116
  await onMessage(msg).catch(() => { });
105
117
  }
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
8
8
  // ONLY injects into panes that opted in by registering — never an arbitrary pane.
9
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { chmodSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { join } from "node:path";
12
12
  import { homedir } from "node:os";
@@ -26,8 +26,16 @@ function load() {
26
26
  }
27
27
  }
28
28
  function save(routes) {
29
- mkdirSync(dir(), { recursive: true });
30
- writeFileSync(storePath(), JSON.stringify({ routes }, null, 2));
29
+ mkdirSync(dir(), { recursive: true, mode: 0o700 });
30
+ try {
31
+ chmodSync(dir(), 0o700);
32
+ }
33
+ catch { /* best effort */ }
34
+ writeFileSync(storePath(), JSON.stringify({ routes }, null, 2), { mode: 0o600 });
35
+ try {
36
+ chmodSync(storePath(), 0o600);
37
+ }
38
+ catch { /* best effort */ }
31
39
  }
32
40
  /** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
33
41
  * reply; "bind" = persistent (every reply injects until unbound). */
@@ -10,11 +10,10 @@
10
10
  // chunked protocol → AES-256-CBC decrypt inbound media that carries an `aeskey`. Every request frame carries a
11
11
  // `headers.req_id` and the server echoes it back so request/response are correlated. v1 limitations are documented
12
12
  // at the bottom of this file (the public spec is thin — fields are best-effort and tolerant of shape drift).
13
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
- import { join, basename, extname } from "node:path";
15
- import { homedir } from "node:os";
13
+ import { basename, extname } from "node:path";
16
14
  import { randomUUID, createDecipheriv, createHash } from "node:crypto";
17
15
  import { chunkText } from "./telegram.js";
16
+ import { InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes } from "./media.js";
18
17
  const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
19
18
  const WSImpl = globalThis.WebSocket;
20
19
  // app-level command verbs on the AI Bot gateway (mirror the Hermes constants)
@@ -153,45 +152,48 @@ export function parseWecomMessage(payload, selfBotId) {
153
152
  const media = extractWecomMedia(body);
154
153
  if (!text && !media.length)
155
154
  return null; // unsupported type or empty
155
+ // WeCom AI Bot callbacks use chattype="single" or "group". Only the explicit single value proves a DM;
156
+ // missing/unknown types fail closed as group even when chatid happens to be absent.
157
+ const chatType = String(body.chattype ?? "").trim().toLowerCase() === "single" ? "p2p" : "group";
156
158
  return {
157
159
  msg: {
158
160
  chatId,
159
161
  userId: senderId || chatId,
160
162
  userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
161
163
  text: text || "[图片]",
164
+ chatType,
162
165
  },
163
166
  media,
164
167
  };
165
168
  }
166
169
  /** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
167
170
  * failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
168
- async function downloadWecomMedia(ref) {
171
+ async function downloadWecomMedia(ref, options) {
169
172
  try {
170
173
  let data = null;
171
174
  if (ref.base64) {
172
- data = Buffer.from(ref.base64.split(",").pop().trim(), "base64");
175
+ data = decodeBase64Media(ref.base64, options.maxBytes);
173
176
  }
174
177
  else if (ref.url) {
175
- const r = await fetch(ref.url);
178
+ const r = await fetch(ref.url, { signal: options.signal });
176
179
  if (!r.ok)
177
180
  return null;
178
- let buf = Buffer.from(await r.arrayBuffer());
179
- if (buf.length > ABSOLUTE_MAX_BYTES)
180
- return null;
181
- if (ref.aesKeyB64)
182
- buf = decryptWecomMedia(buf, ref.aesKeyB64);
183
- data = buf;
181
+ data = await readResponseBytesLimited(r, options.maxBytes, options.signal);
184
182
  }
185
183
  if (!data || !data.length)
186
184
  return null;
187
- const dir = join(homedir(), ".hara", "wecom", "media");
188
- mkdirSync(dir, { recursive: true });
185
+ if (ref.aesKeyB64)
186
+ data = decryptWecomMedia(data, ref.aesKeyB64);
187
+ if (!data.length || data.length > options.maxBytes)
188
+ return null;
189
189
  let name = ref.fileName ? basename(ref.fileName) : "";
190
190
  if (!name || (ref.kind === "image" && !extname(name)))
191
191
  name = `image${imageExtFromBytes(data)}`;
192
- const path = join(dir, `wc_${Date.now()}_${name || "file.bin"}`);
193
- writeFileSync(path, data);
194
- return path;
192
+ return await savePrivateMediaBytes(data, {
193
+ platform: "wecom",
194
+ filenameHint: name || "file.bin",
195
+ ...options,
196
+ });
195
197
  }
196
198
  catch {
197
199
  return null;
@@ -201,17 +203,17 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
201
203
  // per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
202
204
  // it publishes through `conn`.
203
205
  const conn = { send: null };
204
- // Upload a local file to WeCom via the 3-step chunked protocol → its media_id. Throws on any step error.
205
- async function uploadMedia(filePath, mediaType) {
206
+ // Upload already-verified bytes to WeCom via the 3-step chunked protocol → its media_id.
207
+ async function uploadMedia(file, mediaType) {
206
208
  if (!conn.send)
207
209
  throw new Error("WeCom socket not connected");
208
- const data = readFileSync(filePath);
210
+ const data = file.bytes;
209
211
  if (data.length > ABSOLUTE_MAX_BYTES)
210
212
  throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
211
213
  const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
212
214
  const init = await conn.send(CMD_UPLOAD_INIT, {
213
215
  type: mediaType,
214
- filename: basename(filePath),
216
+ filename: file.safeName,
215
217
  total_size: data.length,
216
218
  total_chunks: totalChunks,
217
219
  md5: createHash("md5").update(data).digest("hex"),
@@ -242,25 +244,25 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
242
244
  .catch(() => { });
243
245
  }
244
246
  },
245
- async sendFile(chatId, filePath) {
247
+ async sendFile(chatId, file) {
246
248
  if (!conn.send)
247
249
  return;
248
- const mediaType = isImageName(filePath) ? "image" : "file";
250
+ const mediaType = isImageName(file.safeName) ? "image" : "file";
249
251
  try {
250
- const mediaId = await uploadMedia(filePath, mediaType);
252
+ const mediaId = await uploadMedia(file, mediaType);
251
253
  await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
252
254
  }
253
255
  catch {
254
256
  /* upload/send failed — caller surfaces a generic failure; nothing else to do */
255
257
  }
256
258
  },
257
- async start(onMessage, signal) {
259
+ async start(onMessage, signal, shouldDownload) {
258
260
  if (!WSImpl) {
259
261
  console.error("hara gateway: WeCom needs Node ≥ 22 (global WebSocket). Upgrade Node.");
260
262
  return;
261
263
  }
262
264
  while (!signal.aborted) {
263
- await connectOnce(botId, secret, wsUrl, conn, onMessage, signal);
265
+ await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload);
264
266
  if (!signal.aborted)
265
267
  await sleep(3000, signal); // reconnect backoff
266
268
  }
@@ -270,7 +272,7 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
270
272
  /** One gateway connection: open WS → `aibot_subscribe` auth → correlate request/response by req_id, dispatch
271
273
  * `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
272
274
  * subscribe each time — v1 keeps it simple, no resume). */
273
- function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
275
+ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload) {
274
276
  return new Promise((resolve) => {
275
277
  const ws = new WSImpl(wsUrl);
276
278
  let hb = null;
@@ -371,10 +373,15 @@ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
371
373
  }
372
374
  const parsed = parseWecomMessage(p, botId);
373
375
  if (parsed) {
374
- for (const ref of parsed.media) {
375
- const path = await downloadWecomMedia(ref);
376
- if (path)
377
- (parsed.msg.images ??= []).push(path);
376
+ if (shouldDownload?.(parsed.msg) === true) {
377
+ const budget = new InboundMediaBudget("wecom", signal);
378
+ for (const ref of parsed.media) {
379
+ const path = await budget.download((options) => downloadWecomMedia(ref, options));
380
+ if (path) {
381
+ (parsed.msg.images ??= []).push(path);
382
+ (parsed.msg.transientFiles ??= []).push(path);
383
+ }
384
+ }
378
385
  }
379
386
  await onMessage(parsed.msg).catch(() => { });
380
387
  }