@nanhara/hara 0.122.0 → 0.122.2
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 +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- 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/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- 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/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/gateway/serve.js
CHANGED
|
@@ -16,6 +16,8 @@ import { listSessions, loadSession } from "../session/store.js";
|
|
|
16
16
|
import { homedir, tmpdir } from "node:os";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
19
|
+
import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
|
|
20
|
+
import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
|
|
19
21
|
/** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
|
|
20
22
|
export function parseCommand(text) {
|
|
21
23
|
const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
|
|
@@ -45,13 +47,35 @@ export function canonicalGatewayPlatform(platform) {
|
|
|
45
47
|
/** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
|
|
46
48
|
* (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
|
|
47
49
|
export function cleanReply(raw) {
|
|
48
|
-
return raw
|
|
50
|
+
return redactToolSubprocessOutput(raw)
|
|
49
51
|
.split("\n")
|
|
50
52
|
.filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
|
|
51
53
|
.join("\n")
|
|
52
54
|
.trim();
|
|
53
55
|
}
|
|
54
56
|
let outboxSeq = 0;
|
|
57
|
+
/** Snapshot and materialize a direct gateway file before an adapter sees it. This also covers TTS output, so
|
|
58
|
+
* every outbound attachment crosses the same verified-byte boundary rather than handing adapters a pathname. */
|
|
59
|
+
async function deliverVerifiedLocalFile(adapter, chatId, sourcePath) {
|
|
60
|
+
const sendFile = adapter.sendFile;
|
|
61
|
+
if (!sendFile)
|
|
62
|
+
throw new Error("this platform can't send files yet");
|
|
63
|
+
const outbox = join(tmpdir(), `hara-direct-send-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
await queueOutboundSnapshot(sourcePath, outbox);
|
|
67
|
+
[payload] = await consumeOutboundSnapshots(outbox);
|
|
68
|
+
cleanupOutboundSnapshots(outbox, payload ? [payload.snapshotPath] : []);
|
|
69
|
+
if (!payload)
|
|
70
|
+
throw new Error("the private file snapshot could not be verified");
|
|
71
|
+
await sendFile(chatId, payload);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (payload)
|
|
75
|
+
cleanupOutboundSnapshot(payload.snapshotPath);
|
|
76
|
+
cleanupOutboundSnapshots(outbox);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
55
79
|
export class GatewayQueueFullError extends Error {
|
|
56
80
|
key;
|
|
57
81
|
limit;
|
|
@@ -234,20 +258,6 @@ function durationLabel(ms) {
|
|
|
234
258
|
return `${ms / 60_000}m`;
|
|
235
259
|
return `${Math.round(ms / 1_000)}s`;
|
|
236
260
|
}
|
|
237
|
-
function signalProcess(child, signal) {
|
|
238
|
-
try {
|
|
239
|
-
// A detached POSIX child leads its own process group. Kill that group so bash/tool grandchildren cannot
|
|
240
|
-
// survive a timed-out agent. Windows has no negative-pid process-group signaling; use ChildProcess.kill.
|
|
241
|
-
if (process.platform !== "win32" && child.pid) {
|
|
242
|
-
process.kill(-child.pid, signal);
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
return child.kill(signal);
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
261
|
/** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
|
|
252
262
|
* via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
|
|
253
263
|
* in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
|
|
@@ -287,10 +297,12 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
|
|
|
287
297
|
let exitCode = null;
|
|
288
298
|
let exitSignal = null;
|
|
289
299
|
let stopReason;
|
|
290
|
-
let killTimer;
|
|
291
300
|
let drainTimer;
|
|
292
|
-
let
|
|
301
|
+
let forceIssued = false;
|
|
302
|
+
let cancelTermination;
|
|
293
303
|
const cap = (d) => {
|
|
304
|
+
if (settled || stopReason)
|
|
305
|
+
return;
|
|
294
306
|
out = (out + d.toString().slice(-12_000)).slice(-12_000);
|
|
295
307
|
};
|
|
296
308
|
child.stdout?.on("data", cap);
|
|
@@ -300,17 +312,11 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
|
|
|
300
312
|
child.unref();
|
|
301
313
|
child.stdout?.unref?.();
|
|
302
314
|
child.stderr?.unref?.();
|
|
303
|
-
const readOutbox = () => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
rmSync(outbox, { force: true });
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
/* outbox is best-effort; a missing/unreadable file just means nothing to send */
|
|
313
|
-
}
|
|
315
|
+
const readOutbox = async () => {
|
|
316
|
+
const files = await consumeOutboundSnapshots(outbox);
|
|
317
|
+
// Remove abandoned partials and invalid queue entries, but retain accepted snapshots until the adapter
|
|
318
|
+
// has finished uploading them in the parent gateway process.
|
|
319
|
+
cleanupOutboundSnapshots(outbox, files.map((file) => file.snapshotPath));
|
|
314
320
|
return files;
|
|
315
321
|
};
|
|
316
322
|
const finish = (reply) => {
|
|
@@ -318,18 +324,24 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
|
|
|
318
324
|
return;
|
|
319
325
|
settled = true;
|
|
320
326
|
clearTimeout(timeoutTimer);
|
|
321
|
-
if (killTimer)
|
|
322
|
-
clearTimeout(killTimer);
|
|
323
327
|
if (drainTimer)
|
|
324
328
|
clearTimeout(drainTimer);
|
|
325
|
-
|
|
326
|
-
|
|
329
|
+
// The helper deliberately keeps its force timer alive by default. When a direct child closes after
|
|
330
|
+
// TERM, that timer must still SIGKILL the owned group before this stopped run can be considered gone.
|
|
331
|
+
cancelTermination?.();
|
|
327
332
|
options.signal?.removeEventListener("abort", abortRun);
|
|
328
333
|
child.stdout?.destroy();
|
|
329
334
|
child.stderr?.destroy();
|
|
330
|
-
res({ reply, files
|
|
335
|
+
void readOutbox().then((files) => res({ reply, files }), () => {
|
|
336
|
+
cleanupOutboundSnapshots(outbox);
|
|
337
|
+
res({ reply, files: [] });
|
|
338
|
+
});
|
|
331
339
|
};
|
|
332
340
|
const finishFromExit = () => {
|
|
341
|
+
// `exit`/`close` describes only the direct child. During a timeout/shutdown, wait until the forced
|
|
342
|
+
// process-group signal has been issued so a quiet TERM-resistant descendant cannot escape cleanup.
|
|
343
|
+
if (stopReason && !forceIssued)
|
|
344
|
+
return;
|
|
333
345
|
if (stopReason === "timeout") {
|
|
334
346
|
finish(runFailure(`hara timed out after ${durationLabel(timeoutMs)}; the run was stopped.`, out));
|
|
335
347
|
}
|
|
@@ -348,31 +360,47 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
|
|
|
348
360
|
}
|
|
349
361
|
};
|
|
350
362
|
const terminate = (reason) => {
|
|
351
|
-
if (settled ||
|
|
363
|
+
if (settled || cancelTermination)
|
|
352
364
|
return;
|
|
353
365
|
stopReason ??= reason;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
366
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
367
|
+
processGroup: process.platform !== "win32",
|
|
368
|
+
graceMs: killGraceMs,
|
|
369
|
+
fallbackMs: 250,
|
|
370
|
+
onForce: () => {
|
|
371
|
+
forceIssued = true;
|
|
372
|
+
if (exited)
|
|
373
|
+
finishFromExit();
|
|
374
|
+
},
|
|
375
|
+
// A daemon may escape the group or a runtime may fail to emit exit/close. Destroying our pipe ends in
|
|
376
|
+
// `finish` keeps gateway shutdown and per-run timeout bounded even in that case.
|
|
377
|
+
onFallback: finishFromExit,
|
|
378
|
+
});
|
|
363
379
|
};
|
|
364
380
|
const abortRun = () => terminate("shutdown");
|
|
365
381
|
const timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
|
|
366
382
|
if (options.signal)
|
|
367
383
|
options.signal.addEventListener("abort", abortRun, { once: true });
|
|
368
|
-
|
|
384
|
+
// Close the narrow race where the signal aborts after the entry check but before the listener is attached.
|
|
385
|
+
if (options.signal?.aborted)
|
|
386
|
+
abortRun();
|
|
387
|
+
child.once("error", (error) => {
|
|
388
|
+
if (!stopReason)
|
|
389
|
+
return finish(runFailure(`couldn't start hara: ${error.message}`, out));
|
|
390
|
+
exited = true;
|
|
391
|
+
if (forceIssued)
|
|
392
|
+
finishFromExit();
|
|
393
|
+
});
|
|
369
394
|
child.once("exit", (code, signal) => {
|
|
370
395
|
exited = true;
|
|
371
396
|
exitCode = code;
|
|
372
397
|
exitSignal = signal;
|
|
373
398
|
// Usually `close` follows after the final pipe data. A grandchild can retain stdout/stderr forever, so
|
|
374
399
|
// cap that drain window and then destroy the pipes ourselves.
|
|
375
|
-
|
|
400
|
+
if (stopReason)
|
|
401
|
+
finishFromExit();
|
|
402
|
+
else
|
|
403
|
+
drainTimer = setTimeout(finishFromExit, 100);
|
|
376
404
|
});
|
|
377
405
|
child.once("close", (code, signal) => {
|
|
378
406
|
exited = true;
|
|
@@ -749,17 +777,26 @@ export async function runGateway(opts) {
|
|
|
749
777
|
const audio = await synthesize(cmd.arg);
|
|
750
778
|
if (!audio)
|
|
751
779
|
return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
|
|
752
|
-
|
|
753
|
-
|
|
780
|
+
try {
|
|
781
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, audio);
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
rmSync(audio, { force: true });
|
|
785
|
+
}
|
|
754
786
|
return;
|
|
755
787
|
}
|
|
756
788
|
if (cmd.cmd === "send") {
|
|
757
789
|
if (!adapter.sendFile)
|
|
758
790
|
return adapter.send(m.chatId, "this platform can't send files yet.");
|
|
759
791
|
const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
|
|
760
|
-
if (!p
|
|
761
|
-
return adapter.send(m.chatId,
|
|
762
|
-
|
|
792
|
+
if (!p)
|
|
793
|
+
return adapter.send(m.chatId, "usage: /send <path> (abs, ~, or relative to current dir)");
|
|
794
|
+
try {
|
|
795
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, p);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
return adapter.send(m.chatId, `✗ couldn't send ${p}: ${error instanceof Error ? error.message : String(error)}`);
|
|
799
|
+
}
|
|
763
800
|
return;
|
|
764
801
|
}
|
|
765
802
|
// any other slash word → treat as a normal task
|
|
@@ -791,33 +828,44 @@ export async function runGateway(opts) {
|
|
|
791
828
|
if (workingId && adapter.recall)
|
|
792
829
|
await adapter.recall(m.chatId, workingId).catch(() => { });
|
|
793
830
|
}
|
|
794
|
-
if (ac.signal.aborted)
|
|
795
|
-
return;
|
|
796
831
|
const { reply, files } = result;
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
832
|
+
try {
|
|
833
|
+
if (ac.signal.aborted)
|
|
834
|
+
return;
|
|
835
|
+
const hasReply = Boolean(reply);
|
|
836
|
+
if (hasReply)
|
|
837
|
+
await adapter.send(m.chatId, plainChat(reply)); // chat bubbles are plain text — flatten markdown
|
|
838
|
+
else if (files.length)
|
|
839
|
+
await adapter.send(m.chatId, "📎");
|
|
840
|
+
// Deliver only immutable private snapshots produced by send_file.
|
|
841
|
+
for (const f of files) {
|
|
842
|
+
if (!adapter.sendFile) {
|
|
843
|
+
await adapter.send(m.chatId, "(this platform can't send files yet)");
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await adapter.sendFile(m.chatId, f);
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
await adapter.send(m.chatId, `✗ couldn't send attachment: ${e.message}`);
|
|
851
|
+
}
|
|
810
852
|
}
|
|
811
|
-
|
|
812
|
-
|
|
853
|
+
if (hasReply && ctx.voice && adapter.sendFile) {
|
|
854
|
+
const audio = await synthesize(reply);
|
|
855
|
+
if (audio) {
|
|
856
|
+
try {
|
|
857
|
+
await deliverVerifiedLocalFile(adapter, m.chatId, audio);
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
rmSync(audio, { force: true });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
813
863
|
}
|
|
814
864
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
rmSync(audio, { force: true });
|
|
820
|
-
}
|
|
865
|
+
finally {
|
|
866
|
+
// Text-send failures, shutdown races, unsupported adapters, and upload failures all remove snapshots.
|
|
867
|
+
for (const f of files)
|
|
868
|
+
cleanupOutboundSnapshot(f.snapshotPath);
|
|
821
869
|
}
|
|
822
870
|
}
|
|
823
871
|
finally {
|
package/dist/gateway/signal.js
CHANGED
|
@@ -138,7 +138,7 @@ export function signalAdapter(rpcUrl, selfNumber) {
|
|
|
138
138
|
return null;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
/** Recipient/group routing
|
|
141
|
+
/** Recipient/group routing for outbound text sends (mirrors hermes). */
|
|
142
142
|
const target = (chatId) => {
|
|
143
143
|
const id = String(chatId);
|
|
144
144
|
return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
|
|
@@ -169,11 +169,9 @@ export function signalAdapter(rpcUrl, selfNumber) {
|
|
|
169
169
|
await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
|
|
170
170
|
}
|
|
171
171
|
},
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
|
|
176
|
-
},
|
|
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.
|
|
177
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) {
|
package/dist/gateway/slack.js
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
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 } from "node:fs";
|
|
8
|
-
import { basename } from "node:path";
|
|
9
7
|
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
10
8
|
import { chunkText } from "./telegram.js";
|
|
11
9
|
const WEB = "https://slack.com/api";
|
|
@@ -93,16 +91,16 @@ export function slackAdapter(appToken, botToken) {
|
|
|
93
91
|
await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
|
|
94
92
|
}
|
|
95
93
|
},
|
|
96
|
-
async sendFile(chatId,
|
|
94
|
+
async sendFile(chatId, file) {
|
|
97
95
|
// 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
|
|
98
96
|
// non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
|
|
99
97
|
try {
|
|
100
|
-
const bytes =
|
|
101
|
-
const name =
|
|
98
|
+
const bytes = file.bytes;
|
|
99
|
+
const name = file.safeName;
|
|
102
100
|
const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
|
|
103
101
|
if (!url?.ok || !url.upload_url || !url.file_id)
|
|
104
102
|
return;
|
|
105
|
-
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
|
|
106
104
|
if (!up.ok)
|
|
107
105
|
return;
|
|
108
106
|
await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
|
package/dist/gateway/telegram.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
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 } from "node:fs";
|
|
5
|
-
import { basename } from "node:path";
|
|
6
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));
|
|
@@ -82,13 +80,13 @@ export function telegramAdapter(token) {
|
|
|
82
80
|
});
|
|
83
81
|
}
|
|
84
82
|
},
|
|
85
|
-
async sendFile(chatId,
|
|
83
|
+
async sendFile(chatId, file) {
|
|
86
84
|
// images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
|
|
87
|
-
const name =
|
|
85
|
+
const name = file.safeName;
|
|
88
86
|
const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
89
87
|
const form = new FormData();
|
|
90
88
|
form.append("chat_id", String(chatId));
|
|
91
|
-
form.append(isImg ? "photo" : "document", new Blob([
|
|
89
|
+
form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
|
|
92
90
|
await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
|
|
93
91
|
},
|
|
94
92
|
async start(onMessage, signal, shouldDownload) {
|
|
@@ -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
|
-
|
|
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). */
|
package/dist/gateway/wecom.js
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
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 } from "node:fs";
|
|
14
13
|
import { basename, extname } from "node:path";
|
|
15
14
|
import { randomUUID, createDecipheriv, createHash } from "node:crypto";
|
|
16
15
|
import { chunkText } from "./telegram.js";
|
|
@@ -204,17 +203,17 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
|
204
203
|
// per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
|
|
205
204
|
// it publishes through `conn`.
|
|
206
205
|
const conn = { send: null };
|
|
207
|
-
// Upload
|
|
208
|
-
async function uploadMedia(
|
|
206
|
+
// Upload already-verified bytes to WeCom via the 3-step chunked protocol → its media_id.
|
|
207
|
+
async function uploadMedia(file, mediaType) {
|
|
209
208
|
if (!conn.send)
|
|
210
209
|
throw new Error("WeCom socket not connected");
|
|
211
|
-
const data =
|
|
210
|
+
const data = file.bytes;
|
|
212
211
|
if (data.length > ABSOLUTE_MAX_BYTES)
|
|
213
212
|
throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
|
|
214
213
|
const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
|
|
215
214
|
const init = await conn.send(CMD_UPLOAD_INIT, {
|
|
216
215
|
type: mediaType,
|
|
217
|
-
filename:
|
|
216
|
+
filename: file.safeName,
|
|
218
217
|
total_size: data.length,
|
|
219
218
|
total_chunks: totalChunks,
|
|
220
219
|
md5: createHash("md5").update(data).digest("hex"),
|
|
@@ -245,12 +244,12 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
|
245
244
|
.catch(() => { });
|
|
246
245
|
}
|
|
247
246
|
},
|
|
248
|
-
async sendFile(chatId,
|
|
247
|
+
async sendFile(chatId, file) {
|
|
249
248
|
if (!conn.send)
|
|
250
249
|
return;
|
|
251
|
-
const mediaType = isImageName(
|
|
250
|
+
const mediaType = isImageName(file.safeName) ? "image" : "file";
|
|
252
251
|
try {
|
|
253
|
-
const mediaId = await uploadMedia(
|
|
252
|
+
const mediaId = await uploadMedia(file, mediaType);
|
|
254
253
|
await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
|
|
255
254
|
}
|
|
256
255
|
catch {
|
package/dist/gateway/weixin.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
9
|
-
import { basename } from "node:path";
|
|
10
9
|
import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
|
|
11
10
|
import { chunkText } from "./telegram.js";
|
|
12
11
|
import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
|
|
@@ -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 */
|
|
@@ -501,14 +511,14 @@ async function cdnUpload(uploadUrl, ciphertext) {
|
|
|
501
511
|
throw new Error("CDN upload missing x-encrypted-param header");
|
|
502
512
|
return ep;
|
|
503
513
|
}
|
|
504
|
-
/** Send
|
|
514
|
+
/** Send already-verified bytes to a peer. Images go inline (image_item); everything else (audio/zip/pdf/doc/…) goes as a
|
|
505
515
|
* file attachment (file_item) carrying the filename. Ported byte-exact from iLink's media protocol:
|
|
506
516
|
* getuploadurl → AES-128-ECB encrypt → CDN POST → sendmessage(item). */
|
|
507
|
-
export async function sendMediaFile(creds, tokenStore, peer,
|
|
517
|
+
export async function sendMediaFile(creds, tokenStore, peer, file) {
|
|
508
518
|
try {
|
|
509
|
-
const plaintext =
|
|
519
|
+
const plaintext = file.bytes;
|
|
510
520
|
const rawsize = plaintext.length;
|
|
511
|
-
const isImage = IMAGE_EXTS.has((
|
|
521
|
+
const isImage = IMAGE_EXTS.has((file.safeName.split(".").pop() || "").toLowerCase());
|
|
512
522
|
const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
|
|
513
523
|
const filekey = randomBytes(16).toString("hex");
|
|
514
524
|
const key = randomBytes(16);
|
|
@@ -524,7 +534,7 @@ export async function sendMediaFile(creds, tokenStore, peer, filePath) {
|
|
|
524
534
|
const encryptedParam = await cdnUpload(uploadUrl, ciphertext);
|
|
525
535
|
const item = isImage
|
|
526
536
|
? imageInlineItem(encryptedParam, apiAesKey(keyHex), ciphertext.length)
|
|
527
|
-
: audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize,
|
|
537
|
+
: audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, file.safeName);
|
|
528
538
|
const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
|
|
529
539
|
const send = (ctx) => {
|
|
530
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] };
|
|
@@ -623,9 +633,9 @@ export function weixinAdapter(creds) {
|
|
|
623
633
|
for (const part of chunkText(text || "(empty)"))
|
|
624
634
|
await sendChunk(creds, tokenStore, peer, part);
|
|
625
635
|
},
|
|
626
|
-
async sendFile(chatId,
|
|
627
|
-
if (!(await sendMediaFile(creds, tokenStore, String(chatId),
|
|
628
|
-
throw new Error(`weixin file delivery failed: ${
|
|
636
|
+
async sendFile(chatId, file) {
|
|
637
|
+
if (!(await sendMediaFile(creds, tokenStore, String(chatId), file)))
|
|
638
|
+
throw new Error(`weixin file delivery failed: ${file.safeName}`);
|
|
629
639
|
},
|
|
630
640
|
async start(onMessage, signal, shouldDownload) {
|
|
631
641
|
let buf = loadCursor(creds.account_id);
|
package/dist/hooks.js
CHANGED
|
@@ -6,6 +6,8 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { pluginHooks } from "./plugins/plugins.js";
|
|
9
|
+
import { redactToolSubprocessOutput, toolSubprocessEnv } from "./security/subprocess-env.js";
|
|
10
|
+
import { shellCommand } from "./sandbox.js";
|
|
9
11
|
const cache = new Map();
|
|
10
12
|
export function resetHooksCache() {
|
|
11
13
|
cache.clear();
|
|
@@ -47,28 +49,32 @@ export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
|
|
|
47
49
|
continue;
|
|
48
50
|
let r;
|
|
49
51
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
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, {
|
|
52
56
|
cwd,
|
|
53
57
|
input: JSON.stringify({ tool: toolName, payload }),
|
|
54
58
|
encoding: "utf8",
|
|
55
59
|
timeout: timeoutMs,
|
|
56
|
-
env:
|
|
60
|
+
env: toolSubprocessEnv(process.env, { HARA_TOOL_NAME: toolName }),
|
|
57
61
|
});
|
|
58
62
|
}
|
|
59
63
|
catch (error) {
|
|
60
64
|
if (event === "PreToolUse") {
|
|
61
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
65
|
+
const detail = redactToolSubprocessOutput(error instanceof Error ? error.message : String(error));
|
|
62
66
|
return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
|
|
63
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.
|
|
64
70
|
continue;
|
|
65
71
|
}
|
|
66
72
|
// A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
|
|
67
73
|
// writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
|
|
68
74
|
// that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
|
|
69
75
|
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"}`);
|
|
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"}`));
|
|
72
78
|
return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
|
|
73
79
|
}
|
|
74
80
|
}
|