@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.
- package/CHANGELOG.md +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/gateway/weixin.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
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";
|
|
9
|
-
import { basename } from "node:path";
|
|
8
|
+
import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
10
9
|
import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
|
|
11
10
|
import { chunkText } from "./telegram.js";
|
|
11
|
+
import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
|
|
12
12
|
const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
13
13
|
const CHANNEL_VERSION = "2.2.0";
|
|
14
14
|
const ILINK_APP_ID = "bot";
|
|
@@ -44,7 +44,6 @@ const WEIXIN_CDN_ALLOWLIST = new Set([
|
|
|
44
44
|
"mmbiz.qlogo.cn",
|
|
45
45
|
]);
|
|
46
46
|
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
47
|
const num = (v) => (typeof v === "number" ? v : 0);
|
|
49
48
|
const str = (v) => (v == null ? "" : String(v));
|
|
50
49
|
// ── pure protocol helpers (unit-tested) ──────────────────────────────────────
|
|
@@ -198,8 +197,16 @@ export function loadWeixinCreds() {
|
|
|
198
197
|
}
|
|
199
198
|
}
|
|
200
199
|
function saveWeixinCreds(c) {
|
|
201
|
-
mkdirSync(weixinDir(), { recursive: true });
|
|
200
|
+
mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
|
|
201
|
+
try {
|
|
202
|
+
chmodSync(weixinDir(), 0o700);
|
|
203
|
+
}
|
|
204
|
+
catch { /* best effort */ }
|
|
202
205
|
writeFileSync(credsFile(), JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
206
|
+
try {
|
|
207
|
+
chmodSync(credsFile(), 0o600);
|
|
208
|
+
}
|
|
209
|
+
catch { /* best effort */ }
|
|
203
210
|
}
|
|
204
211
|
// get_updates_buf cursor — persisted so a restart resumes the message stream where it left off.
|
|
205
212
|
function loadCursor(accountId) {
|
|
@@ -213,8 +220,11 @@ function loadCursor(accountId) {
|
|
|
213
220
|
}
|
|
214
221
|
function saveCursor(accountId, buf) {
|
|
215
222
|
try {
|
|
216
|
-
mkdirSync(weixinDir(), { recursive: true });
|
|
217
|
-
|
|
223
|
+
mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
|
|
224
|
+
chmodSync(weixinDir(), 0o700);
|
|
225
|
+
const file = join(weixinDir(), `${accountId}.cursor`);
|
|
226
|
+
writeFileSync(file, buf, { mode: 0o600 });
|
|
227
|
+
chmodSync(file, 0o600);
|
|
218
228
|
}
|
|
219
229
|
catch {
|
|
220
230
|
/* best-effort */
|
|
@@ -223,40 +233,74 @@ function saveCursor(accountId, buf) {
|
|
|
223
233
|
// Per-peer context_token: every reply must echo the latest token iLink sent for that peer.
|
|
224
234
|
class TokenStore {
|
|
225
235
|
accountId;
|
|
226
|
-
cache =
|
|
236
|
+
cache = new Map();
|
|
227
237
|
constructor(accountId) {
|
|
228
238
|
this.accountId = accountId;
|
|
229
239
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
240
|
+
const exists = existsSync(this.file());
|
|
241
|
+
const parsed = exists ? JSON.parse(readFileSync(this.file(), "utf8")) : {};
|
|
242
|
+
if (exists)
|
|
243
|
+
chmodSync(this.file(), 0o600);
|
|
244
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
245
|
+
for (const [peer, token] of Object.entries(parsed).slice(-1000)) {
|
|
246
|
+
if (peer && typeof token === "string" && token)
|
|
247
|
+
this.cache.set(peer, token);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
232
250
|
}
|
|
233
251
|
catch {
|
|
234
|
-
this.cache
|
|
252
|
+
this.cache.clear();
|
|
235
253
|
}
|
|
236
254
|
}
|
|
237
255
|
file() {
|
|
238
256
|
return join(weixinDir(), `${this.accountId}.context-tokens.json`);
|
|
239
257
|
}
|
|
258
|
+
peerKey(peer) {
|
|
259
|
+
return String(peer ?? "").trim().slice(0, 256);
|
|
260
|
+
}
|
|
240
261
|
persist() {
|
|
262
|
+
const temporary = `${this.file()}.${process.pid}.${randomUUID()}.tmp`;
|
|
241
263
|
try {
|
|
242
|
-
mkdirSync(weixinDir(), { recursive: true });
|
|
243
|
-
|
|
264
|
+
mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
|
|
265
|
+
chmodSync(weixinDir(), 0o700);
|
|
266
|
+
writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cache)), { mode: 0o600, flag: "wx" });
|
|
267
|
+
renameSync(temporary, this.file());
|
|
268
|
+
chmodSync(this.file(), 0o600);
|
|
244
269
|
}
|
|
245
270
|
catch {
|
|
246
271
|
/* best-effort */
|
|
247
272
|
}
|
|
273
|
+
finally {
|
|
274
|
+
try {
|
|
275
|
+
rmSync(temporary, { force: true });
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* best-effort */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
248
281
|
}
|
|
249
282
|
get(peer) {
|
|
250
|
-
|
|
283
|
+
const key = this.peerKey(peer);
|
|
284
|
+
const token = this.cache.get(key);
|
|
285
|
+
if (token) {
|
|
286
|
+
this.cache.delete(key);
|
|
287
|
+
this.cache.set(key, token);
|
|
288
|
+
}
|
|
289
|
+
return token;
|
|
251
290
|
}
|
|
252
291
|
set(peer, token) {
|
|
253
|
-
|
|
254
|
-
|
|
292
|
+
const key = this.peerKey(peer);
|
|
293
|
+
const boundedToken = String(token ?? "").slice(0, 4096);
|
|
294
|
+
if (key && boundedToken) {
|
|
295
|
+
this.cache.delete(key);
|
|
296
|
+
this.cache.set(key, boundedToken);
|
|
297
|
+
while (this.cache.size > 1000)
|
|
298
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
255
299
|
this.persist();
|
|
256
300
|
}
|
|
257
301
|
}
|
|
258
302
|
del(peer) {
|
|
259
|
-
delete
|
|
303
|
+
this.cache.delete(this.peerKey(peer));
|
|
260
304
|
this.persist();
|
|
261
305
|
}
|
|
262
306
|
}
|
|
@@ -333,14 +377,25 @@ async function sendChunk(creds, tokenStore, peer, text) {
|
|
|
333
377
|
const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
|
|
334
378
|
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
379
|
let resp = await post(tokenStore.get(peer));
|
|
380
|
+
// HARA_WX_DEBUG=1 → dump the raw send response (protocol exploration: does iLink return a message id we
|
|
381
|
+
// could use for a future recall/revoke?). Off by default; stderr only.
|
|
382
|
+
if (process.env.HARA_WX_DEBUG === "1")
|
|
383
|
+
console.error("weixin send resp:", JSON.stringify(resp).slice(0, 500));
|
|
336
384
|
let [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
337
385
|
if (isSessionExpired(ret, errcode, errmsg)) {
|
|
338
386
|
tokenStore.del(peer); // stale → drop it and retry once tokenless (iLink accepts that, degraded)
|
|
339
387
|
resp = await post(undefined);
|
|
340
388
|
[ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
341
389
|
}
|
|
390
|
+
// Rate-limit (ret=-2, empty errmsg): iLink throttles cold/rapid proactive pushes. Back off and retry a few
|
|
391
|
+
// times instead of silently dropping the message — the reused clientId dedups so a retry can't double-send.
|
|
392
|
+
for (let attempt = 0; ret === RATE_LIMIT && errcode === 0 && attempt < 3; attempt++) {
|
|
393
|
+
await sleep(1500 * (attempt + 1));
|
|
394
|
+
resp = await post(tokenStore.get(peer));
|
|
395
|
+
[ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
|
|
396
|
+
}
|
|
342
397
|
if (ret !== 0 || errcode !== 0)
|
|
343
|
-
|
|
398
|
+
throw new Error(`weixin send failed: ret=${ret} errcode=${errcode} errmsg=${errmsg || "unknown error"}`);
|
|
344
399
|
}
|
|
345
400
|
function aes128EcbEncrypt(plaintext, key) {
|
|
346
401
|
const c = createCipheriv("aes-128-ecb", key, null); // PKCS7 auto-padding (Node default) matches iLink
|
|
@@ -397,8 +452,6 @@ export function inboundMediaRefs(itemList) {
|
|
|
397
452
|
return out;
|
|
398
453
|
}
|
|
399
454
|
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
455
|
function assertCdnHost(url) {
|
|
403
456
|
let u;
|
|
404
457
|
try {
|
|
@@ -413,7 +466,10 @@ function assertCdnHost(url) {
|
|
|
413
466
|
throw new Error(`host ${u.hostname} not in WeChat CDN allowlist (SSRF guard)`);
|
|
414
467
|
}
|
|
415
468
|
/** 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
|
|
469
|
+
export async function downloadInboundMedia(ref, options = {
|
|
470
|
+
maxBytes: INBOUND_MEDIA_MAX_BYTES,
|
|
471
|
+
signal: AbortSignal.timeout(INBOUND_MEDIA_TIMEOUT_MS),
|
|
472
|
+
}) {
|
|
417
473
|
try {
|
|
418
474
|
let url;
|
|
419
475
|
if (ref.encryptQueryParam)
|
|
@@ -424,22 +480,20 @@ export async function downloadInboundMedia(ref) {
|
|
|
424
480
|
}
|
|
425
481
|
else
|
|
426
482
|
return null;
|
|
427
|
-
const res = await fetch(url, { signal:
|
|
483
|
+
const res = await fetch(url, { signal: options.signal });
|
|
428
484
|
if (!res.ok)
|
|
429
485
|
throw new Error(`media download HTTP ${res.status}`);
|
|
430
|
-
|
|
431
|
-
if (ab.byteLength > INBOUND_MEDIA_MAX)
|
|
432
|
-
throw new Error("media exceeds size cap");
|
|
433
|
-
let buf = Buffer.from(ab);
|
|
486
|
+
let buf = await readResponseBytesLimited(res, options.maxBytes, options.signal);
|
|
434
487
|
if (ref.aesKeyB64)
|
|
435
488
|
buf = aes128EcbDecrypt(buf, parseAesKey(ref.aesKeyB64));
|
|
436
489
|
if (!buf.length)
|
|
437
490
|
return null;
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
491
|
+
const filenameHint = ref.kind === "image" ? "image.jpg" : ref.kind === "voice" ? "audio.silk" : ref.fileName || "document.bin";
|
|
492
|
+
const path = await savePrivateMediaBytes(buf, {
|
|
493
|
+
platform: "weixin",
|
|
494
|
+
filenameHint,
|
|
495
|
+
...options,
|
|
496
|
+
});
|
|
443
497
|
return { path, mime: ref.kind === "image" ? "image/jpeg" : ref.kind === "voice" ? "audio/silk" : "application/octet-stream" };
|
|
444
498
|
}
|
|
445
499
|
catch (e) {
|
|
@@ -457,14 +511,14 @@ async function cdnUpload(uploadUrl, ciphertext) {
|
|
|
457
511
|
throw new Error("CDN upload missing x-encrypted-param header");
|
|
458
512
|
return ep;
|
|
459
513
|
}
|
|
460
|
-
/** Send
|
|
514
|
+
/** Send already-verified bytes to a peer. Images go inline (image_item); everything else (audio/zip/pdf/doc/…) goes as a
|
|
461
515
|
* file attachment (file_item) carrying the filename. Ported byte-exact from iLink's media protocol:
|
|
462
516
|
* getuploadurl → AES-128-ECB encrypt → CDN POST → sendmessage(item). */
|
|
463
|
-
export async function sendMediaFile(creds, tokenStore, peer,
|
|
517
|
+
export async function sendMediaFile(creds, tokenStore, peer, file) {
|
|
464
518
|
try {
|
|
465
|
-
const plaintext =
|
|
519
|
+
const plaintext = file.bytes;
|
|
466
520
|
const rawsize = plaintext.length;
|
|
467
|
-
const isImage = IMAGE_EXTS.has((
|
|
521
|
+
const isImage = IMAGE_EXTS.has((file.safeName.split(".").pop() || "").toLowerCase());
|
|
468
522
|
const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
|
|
469
523
|
const filekey = randomBytes(16).toString("hex");
|
|
470
524
|
const key = randomBytes(16);
|
|
@@ -480,7 +534,7 @@ export async function sendMediaFile(creds, tokenStore, peer, filePath) {
|
|
|
480
534
|
const encryptedParam = await cdnUpload(uploadUrl, ciphertext);
|
|
481
535
|
const item = isImage
|
|
482
536
|
? imageInlineItem(encryptedParam, apiAesKey(keyHex), ciphertext.length)
|
|
483
|
-
: audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize,
|
|
537
|
+
: audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, file.safeName);
|
|
484
538
|
const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
|
|
485
539
|
const send = (ctx) => {
|
|
486
540
|
const msg = { from_user_id: "", to_user_id: peer, client_id: clientId, message_type: MSG_TYPE_BOT, message_state: MSG_STATE_FINISH, item_list: [item] };
|
|
@@ -510,34 +564,67 @@ export function weixinAdapter(creds) {
|
|
|
510
564
|
const tokenStore = new TokenStore(creds.account_id);
|
|
511
565
|
// Reuse parseWeixinMessage for text/voice-transcription, then download any image/file/voice media and append
|
|
512
566
|
// a `[kind: localpath]` reference so hara can read the file. Handles media-only messages (no text) too.
|
|
513
|
-
const buildInbound = async (msg) => {
|
|
567
|
+
const buildInbound = async (msg, signal, shouldDownload) => {
|
|
514
568
|
const parsed = parseWeixinMessage(msg, creds.account_id);
|
|
515
569
|
const from = str(msg?.from_user_id).trim();
|
|
516
570
|
if (!from || from === creds.account_id)
|
|
517
571
|
return null;
|
|
518
|
-
|
|
572
|
+
const chat = guessChatType(msg, creds.account_id);
|
|
573
|
+
const refs = inboundMediaRefs(msg?.item_list);
|
|
574
|
+
let text = parsed?.inbound.text ?? extractText(msg?.item_list);
|
|
575
|
+
const base = {
|
|
576
|
+
chatId: chat.id,
|
|
577
|
+
userId: from,
|
|
578
|
+
userName: from,
|
|
579
|
+
text: text || (refs.some((ref) => ref.kind === "image") ? "[图片]" : refs.length ? "[附件]" : ""),
|
|
580
|
+
chatType: chat.kind === "dm" ? "p2p" : "group",
|
|
581
|
+
};
|
|
582
|
+
if (!base.text)
|
|
519
583
|
return null;
|
|
520
|
-
|
|
584
|
+
const downloadAllowed = base.chatType === "p2p" && shouldDownload?.(base) === true;
|
|
585
|
+
// Persist context only for an authenticated DM, before any download. Rejected senders cannot grow state.
|
|
586
|
+
if (downloadAllowed)
|
|
587
|
+
tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
|
|
521
588
|
const images = [];
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
589
|
+
const transientFiles = [];
|
|
590
|
+
let handedOff = false;
|
|
591
|
+
try {
|
|
592
|
+
if (downloadAllowed) {
|
|
593
|
+
const budget = new InboundMediaBudget("weixin", signal);
|
|
594
|
+
for (const ref of refs) {
|
|
595
|
+
let downloaded = null;
|
|
596
|
+
const path = await budget.download(async (options) => {
|
|
597
|
+
downloaded = await downloadInboundMedia(ref, options);
|
|
598
|
+
return downloaded?.path ?? null;
|
|
599
|
+
});
|
|
600
|
+
if (!path || !downloaded)
|
|
601
|
+
continue;
|
|
602
|
+
transientFiles.push(path);
|
|
603
|
+
if (ref.kind === "image") {
|
|
604
|
+
// images go through as real attachments (seen/described downstream); leave only a light marker in text
|
|
605
|
+
images.push(path);
|
|
606
|
+
if (text !== "[图片]")
|
|
607
|
+
text += `${text ? "\n" : ""}[图片]`;
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
|
|
611
|
+
text = text === "[附件]" ? `[${label}: ${path}]` : `${text}${text ? "\n" : ""}[${label}: ${path}]`;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
534
614
|
}
|
|
615
|
+
const inbound = {
|
|
616
|
+
...base,
|
|
617
|
+
text: text.trim() || base.text,
|
|
618
|
+
images: images.length ? images : undefined,
|
|
619
|
+
transientFiles: transientFiles.length ? transientFiles : undefined,
|
|
620
|
+
};
|
|
621
|
+
handedOff = true;
|
|
622
|
+
return inbound;
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
if (!handedOff && transientFiles.length)
|
|
626
|
+
await cleanupTransientMedia("weixin", transientFiles);
|
|
535
627
|
}
|
|
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
628
|
};
|
|
542
629
|
return {
|
|
543
630
|
name: "weixin",
|
|
@@ -546,10 +633,11 @@ export function weixinAdapter(creds) {
|
|
|
546
633
|
for (const part of chunkText(text || "(empty)"))
|
|
547
634
|
await sendChunk(creds, tokenStore, peer, part);
|
|
548
635
|
},
|
|
549
|
-
async sendFile(chatId,
|
|
550
|
-
await sendMediaFile(creds, tokenStore, String(chatId),
|
|
636
|
+
async sendFile(chatId, file) {
|
|
637
|
+
if (!(await sendMediaFile(creds, tokenStore, String(chatId), file)))
|
|
638
|
+
throw new Error(`weixin file delivery failed: ${file.safeName}`);
|
|
551
639
|
},
|
|
552
|
-
async start(onMessage, signal) {
|
|
640
|
+
async start(onMessage, signal, shouldDownload) {
|
|
553
641
|
let buf = loadCursor(creds.account_id);
|
|
554
642
|
let pollMs = LONG_POLL_TIMEOUT_MS;
|
|
555
643
|
while (!signal.aborted) {
|
|
@@ -578,7 +666,7 @@ export function weixinAdapter(creds) {
|
|
|
578
666
|
buf = str(resp.get_updates_buf) || buf;
|
|
579
667
|
saveCursor(creds.account_id, buf);
|
|
580
668
|
for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
|
|
581
|
-
const inbound = await buildInbound(msg);
|
|
669
|
+
const inbound = await buildInbound(msg, signal, shouldDownload);
|
|
582
670
|
if (inbound)
|
|
583
671
|
await onMessage(inbound).catch(() => { });
|
|
584
672
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
// Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
|
|
2
|
-
// PreToolUse runs BEFORE a tool:
|
|
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
|
-
|
|
9
|
+
import { redactToolSubprocessOutput, toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
10
|
+
import { shellCommand } from "./sandbox.js";
|
|
11
|
+
const cache = new Map();
|
|
9
12
|
export function resetHooksCache() {
|
|
10
|
-
cache
|
|
13
|
+
cache.clear();
|
|
11
14
|
}
|
|
12
|
-
function merged() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
function merged(cwd) {
|
|
16
|
+
const key = resolve(cwd);
|
|
17
|
+
const cached = cache.get(key);
|
|
18
|
+
if (cached)
|
|
19
|
+
return cached;
|
|
20
|
+
const cfg = loadConfig({ cwd: key }).hooks ?? {};
|
|
16
21
|
const plg = pluginHooks();
|
|
17
|
-
|
|
22
|
+
const value = {
|
|
18
23
|
PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
|
|
19
24
|
PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
|
|
20
25
|
};
|
|
21
|
-
|
|
26
|
+
cache.set(key, value);
|
|
27
|
+
return value;
|
|
22
28
|
}
|
|
23
29
|
const matches = (m, name) => {
|
|
24
30
|
if (!m || m === "*")
|
|
@@ -31,33 +37,45 @@ const matches = (m, name) => {
|
|
|
31
37
|
}
|
|
32
38
|
};
|
|
33
39
|
/** 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();
|
|
40
|
+
export function hasHooks(cwd = process.cwd()) {
|
|
41
|
+
const h = merged(cwd);
|
|
36
42
|
return !!(h.PreToolUse?.length || h.PostToolUse?.length);
|
|
37
43
|
}
|
|
38
|
-
/** Run hooks for an event matching `toolName`.
|
|
39
|
-
* PostToolUse
|
|
40
|
-
export function runHooks(event, toolName, payload, cwd) {
|
|
41
|
-
for (const h of merged()[event] ?? []) {
|
|
44
|
+
/** Run hooks for an event matching `toolName`. Any abnormal PreToolUse termination fails closed;
|
|
45
|
+
* PostToolUse remains observe-only. Sync (hooks are short, opt-in); 30s timeout each. */
|
|
46
|
+
export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
|
|
47
|
+
for (const h of merged(cwd)[event] ?? []) {
|
|
42
48
|
if (!matches(h.matcher, toolName))
|
|
43
49
|
continue;
|
|
44
50
|
let r;
|
|
45
51
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
// Hooks are user/plugin-configured external code. Route them through the same command preflight and
|
|
53
|
+
// macOS protected-read mask as Bash, even though hooks remain observe-only after a tool has run.
|
|
54
|
+
const shell = shellCommand(h.command, cwd, "off");
|
|
55
|
+
r = spawnSync(shell.cmd, shell.args, {
|
|
48
56
|
cwd,
|
|
49
57
|
input: JSON.stringify({ tool: toolName, payload }),
|
|
50
58
|
encoding: "utf8",
|
|
51
|
-
timeout:
|
|
52
|
-
env:
|
|
59
|
+
timeout: timeoutMs,
|
|
60
|
+
env: toolSubprocessEnv(process.env, { HARA_TOOL_NAME: toolName }),
|
|
53
61
|
});
|
|
54
62
|
}
|
|
55
|
-
catch {
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (event === "PreToolUse") {
|
|
65
|
+
const detail = redactToolSubprocessOutput(error instanceof Error ? error.message : String(error));
|
|
66
|
+
return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
|
|
67
|
+
}
|
|
68
|
+
// PostToolUse is best-effort: a policy-blocked or broken observer must never rewrite the result of a
|
|
69
|
+
// tool that already completed. In particular, do not retry it through an unsandboxed fallback shell.
|
|
56
70
|
continue;
|
|
57
71
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
// A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
|
|
73
|
+
// writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
|
|
74
|
+
// that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
|
|
75
|
+
if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
|
|
76
|
+
const output = redactToolSubprocessOutput((String(r.stdout ?? "") + String(r.stderr ?? "")).trim());
|
|
77
|
+
const failure = redactToolSubprocessOutput(r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`));
|
|
78
|
+
return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
|
|
61
79
|
}
|
|
62
80
|
}
|
|
63
81
|
return { block: false, message: "" };
|