@securecoms/agent-bridge 0.36.0 → 0.38.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.
- package/README.md +31 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +424 -32
- package/connectors/hermes/securecoms_transport.py +97 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +887 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +127 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +228 -1
- package/connectors/openclaw/openclaw-entry.js +128 -3
- package/dist/attachments.d.ts +33 -1
- package/dist/bin.js +4607 -1020
- package/dist/bridge-agent.d.ts +129 -0
- package/dist/grants/mcp-policy.d.ts +7 -0
- package/dist/index.js +4577 -990
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +1 -1
- package/package.json +1 -1
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
13
15
|
|
|
14
16
|
import pytest
|
|
15
17
|
|
|
18
|
+
import securecoms_transport
|
|
16
19
|
from securecoms_transport import (
|
|
17
20
|
ChannelStatusManager,
|
|
18
21
|
ResolvedTarget,
|
|
@@ -606,3 +609,127 @@ class TestChannelStatusManager:
|
|
|
606
609
|
clock.advance(100.0)
|
|
607
610
|
await mgr.on_typing("ch1")
|
|
608
611
|
assert all(f["state"] != "idle" for f in send.frames)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\r"
|
|
615
|
+
JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01"
|
|
616
|
+
GIF = b"GIF89a\x01\x00\x01\x00\x00\x00"
|
|
617
|
+
WEBP = b"RIFF\x10\x00\x00\x00WEBPVP8 "
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class TestInboundAttachments:
|
|
621
|
+
@pytest.fixture(autouse=True)
|
|
622
|
+
def _dir(self, tmp_path):
|
|
623
|
+
self.root = tmp_path
|
|
624
|
+
self.dir = tmp_path / "attachments" / "chan"
|
|
625
|
+
self.dir.mkdir(parents=True)
|
|
626
|
+
|
|
627
|
+
def _att(self, name, data=JPEG, mime="image/jpeg", directory=None):
|
|
628
|
+
d = directory or self.dir
|
|
629
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
p = d / f"m-{name}"
|
|
631
|
+
p.write_bytes(data)
|
|
632
|
+
return {"filename": name, "mime": mime, "size": len(data), "path": str(p), "sha256": "x"}
|
|
633
|
+
|
|
634
|
+
def _frame(self, attachments):
|
|
635
|
+
return {"type": "message", "channel_id": "chan", "attachments": attachments}
|
|
636
|
+
|
|
637
|
+
def test_each_allowed_image_type_passes_with_the_sniffed_lowercase_mime(self):
|
|
638
|
+
for name, data, mime in [("a.png", PNG, "image/png"), ("b.jpg", JPEG, "image/jpeg"),
|
|
639
|
+
("c.gif", GIF, "image/gif"), ("d.webp", WEBP, "image/webp")]:
|
|
640
|
+
a = self._att(name, data, mime.upper())
|
|
641
|
+
assert securecoms_transport.inbound_attachments(self._frame([a])) == [(a["path"], mime)]
|
|
642
|
+
|
|
643
|
+
def test_non_images_mismatches_and_oversize_are_dropped(self):
|
|
644
|
+
big = PNG + b"\x00" * securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES
|
|
645
|
+
bad = [
|
|
646
|
+
self._att("v.mp3", b"ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00", "audio/mpeg"),
|
|
647
|
+
self._att("d.pdf", b"%PDF-1.7\n....", "application/pdf"),
|
|
648
|
+
self._att("x.svg", b"<svg xmlns='http://www.w3.org/2000/svg'><script/></svg>", "image/svg+xml"),
|
|
649
|
+
self._att("png-as.jpg", PNG, "image/jpeg"),
|
|
650
|
+
self._att("t.png", b"just some text", "image/png"),
|
|
651
|
+
self._att("big.png", big, "image/png"),
|
|
652
|
+
]
|
|
653
|
+
for b in bad:
|
|
654
|
+
assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b["filename"]
|
|
655
|
+
assert securecoms_transport.scan_inbound_attachments(self._frame(bad)) == ([], len(bad))
|
|
656
|
+
|
|
657
|
+
def test_bad_paths_and_names_are_dropped(self):
|
|
658
|
+
good = self._att("ok.jpg")
|
|
659
|
+
outside = self._att("id.jpg", directory=self.root / "secrets" / "chan")
|
|
660
|
+
other = self._att("o.jpg", directory=self.root / "attachments" / "other")
|
|
661
|
+
bad = [
|
|
662
|
+
{**good, "path": "relative/attachments/chan/m-ok.jpg"},
|
|
663
|
+
{**good, "path": f"{self.dir}/../chan/m-ok.jpg"},
|
|
664
|
+
{**good, "path": f"{self.dir}/./m-ok.jpg"},
|
|
665
|
+
{**good, "path": f"{self.dir}/"}, # empty basename (normpath strips the "/")
|
|
666
|
+
{**good, "path": outside["path"]},
|
|
667
|
+
{**good, "path": other["path"]},
|
|
668
|
+
{**good, "path": f"{self.dir}/x\0.jpg"},
|
|
669
|
+
{**good, "path": self._att("rtlgpj.jpg")["path"]},
|
|
670
|
+
{**good, "path": self._att("nl\n.jpg")["path"]},
|
|
671
|
+
{**good, "filename": "invfdp.jpg"},
|
|
672
|
+
{**good, "filename": 5},
|
|
673
|
+
{**good, "path": 42},
|
|
674
|
+
{**good, "mime": 7},
|
|
675
|
+
None,
|
|
676
|
+
"string",
|
|
677
|
+
]
|
|
678
|
+
for b in bad:
|
|
679
|
+
assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b
|
|
680
|
+
assert len(securecoms_transport.inbound_attachments(self._frame(bad + [good]))) == 1
|
|
681
|
+
|
|
682
|
+
def test_the_basename_guard_alone_rejects_an_empty_basename(self, monkeypatch):
|
|
683
|
+
# normpath already strips a trailing "/", so neutralize it to prove the
|
|
684
|
+
# basename guard is a real second check, not dead code.
|
|
685
|
+
good = self._att("ok.jpg")
|
|
686
|
+
monkeypatch.setattr(securecoms_transport.os.path, "normpath", lambda p: p)
|
|
687
|
+
assert securecoms_transport.inbound_attachments(self._frame([{**good, "path": f"{self.dir}/"}])) == []
|
|
688
|
+
|
|
689
|
+
def test_guards_only_normpath_or_isabs_catch(self, monkeypatch):
|
|
690
|
+
# Review r2 mutation survivors: a real image reachable through an
|
|
691
|
+
# unnormalized path whose literal parent is .../attachments/chan.
|
|
692
|
+
self._att("f.png", PNG, "image/png", directory=self.root / "y" / "attachments" / "chan")
|
|
693
|
+
dotted = f"{self.root}/attachments/chan/../../y/attachments/chan/m-f.png"
|
|
694
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": dotted, "mime": "image/png"}])) == []
|
|
695
|
+
# A relative path that resolves to a real image from the cwd.
|
|
696
|
+
self._att("rel.png", PNG, "image/png")
|
|
697
|
+
monkeypatch.chdir(self.root)
|
|
698
|
+
rel = "attachments/chan/m-rel.png"
|
|
699
|
+
assert os.path.isfile(rel)
|
|
700
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": rel, "mime": "image/png"}])) == []
|
|
701
|
+
|
|
702
|
+
def test_a_fifo_is_dropped_without_hanging(self):
|
|
703
|
+
# O_NONBLOCK: without it, open() on a FIFO with no writer blocks forever,
|
|
704
|
+
# so run the scan in a child with a hard timeout.
|
|
705
|
+
fifo = self.dir / "m-hang.png"
|
|
706
|
+
os.mkfifo(fifo)
|
|
707
|
+
frame = {"channel_id": "chan", "attachments": [{"path": str(fifo), "mime": "image/png"}]}
|
|
708
|
+
script = "import json, sys; import securecoms_transport as t; print(json.dumps(t.scan_inbound_attachments(json.loads(sys.stdin.read()))))"
|
|
709
|
+
hermes_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
710
|
+
r = subprocess.run([sys.executable, "-c", script], input=json.dumps(frame), capture_output=True, text=True,
|
|
711
|
+
timeout=15, cwd=hermes_dir, env={**os.environ, "PYTHONPATH": hermes_dir})
|
|
712
|
+
assert r.returncode == 0, r.stderr
|
|
713
|
+
assert json.loads(r.stdout) == [[], 1]
|
|
714
|
+
|
|
715
|
+
def test_a_symlink_is_not_followed(self):
|
|
716
|
+
target = self._att("real.png", PNG, "image/png", directory=self.root / "elsewhere")
|
|
717
|
+
link = self.dir / "m-link.png"
|
|
718
|
+
os.symlink(target["path"], link)
|
|
719
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": str(link), "mime": "image/png"}])) == []
|
|
720
|
+
|
|
721
|
+
def test_all_attachments_share_one_dir(self):
|
|
722
|
+
good = self._att("a.jpg")
|
|
723
|
+
other = self._att("b.jpg", directory=self.root / "x" / "attachments" / "chan")
|
|
724
|
+
assert [p for p, _ in securecoms_transport.inbound_attachments(self._frame([good, other]))] == [good["path"]]
|
|
725
|
+
|
|
726
|
+
def test_count_is_capped(self):
|
|
727
|
+
many = [self._att(f"p{i}.jpg") for i in range(20)]
|
|
728
|
+
assert len(securecoms_transport.inbound_attachments(self._frame(many))) == securecoms_transport.MAX_INBOUND_ATTACHMENTS == 8
|
|
729
|
+
assert securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES == 5 * 1024 * 1024
|
|
730
|
+
|
|
731
|
+
def test_no_list_or_no_channel_is_empty(self):
|
|
732
|
+
good = self._att("a.jpg")
|
|
733
|
+
assert securecoms_transport.inbound_attachments({"channel_id": "chan", "attachments": good}) == []
|
|
734
|
+
assert securecoms_transport.inbound_attachments({"attachments": [good]}) == []
|
|
735
|
+
assert securecoms_transport.inbound_attachments({"channel_id": "chan"}) == []
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.contract.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Bridge -> connector contract (review r1 LOW): a file the daemon's REAL
|
|
4
|
+
// materializeAttachment writes (real crypto, real fs writers) must pass BOTH
|
|
5
|
+
// connector validators unchanged — the OpenClaw scanInboundAttachments and the
|
|
6
|
+
// Hermes securecoms_transport.scan_inbound_attachments — including when the
|
|
7
|
+
// daemon's attachmentsDir carries a trailing "/". A non-image the daemon saves
|
|
8
|
+
// must be dropped by both.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { mkdtempSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { computeFileDigest, decryptFile, encryptFile, base64ToBytes } from "@agentvault/crypto";
|
|
16
|
+
import {
|
|
17
|
+
materializeAttachment,
|
|
18
|
+
mkdirPrivateNoFollow,
|
|
19
|
+
writeFileExclusiveNoFollow,
|
|
20
|
+
type ArtifactShareBody,
|
|
21
|
+
} from "../../src/attachments.js";
|
|
22
|
+
import { inboundAttachmentsOf } from "./openclaw-entry.js";
|
|
23
|
+
|
|
24
|
+
const CH = "3f2b6c1a-8d4e-4f7a-9b2c-1d3e5f7a9b0c";
|
|
25
|
+
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 1, 2, 3]);
|
|
26
|
+
const HERMES_DIR = join(__dirname, "..", "hermes");
|
|
27
|
+
|
|
28
|
+
async function materialize(plain: Uint8Array, name: string, attachmentsDir: string, messageId: string) {
|
|
29
|
+
const { encryptedData, fileKey, fileNonce, digest } = await encryptFile(plain);
|
|
30
|
+
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
|
|
31
|
+
const body: ArtifactShareBody = {
|
|
32
|
+
v: 1, type: "artifact_share", name, mime: "image/png", size: plain.length,
|
|
33
|
+
blobId: "0f3c2a1e-5b6d-4c7e-8f90-a1b2c3d4e5f6", fileKey: b64(fileKey), fileNonce: b64(fileNonce), digest,
|
|
34
|
+
};
|
|
35
|
+
return materializeAttachment(body, { attachmentsDir, channelId: CH, messageId }, {
|
|
36
|
+
ready: async () => {},
|
|
37
|
+
downloadBlob: async () => encryptedData,
|
|
38
|
+
decryptFile, computeFileDigest, base64ToBytes,
|
|
39
|
+
mkdirp: mkdirPrivateNoFollow,
|
|
40
|
+
writeFile: writeFileExclusiveNoFollow,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Visible skip (review r2): the Hermes half needs python3 on the runner.
|
|
45
|
+
const HAS_PYTHON3 = spawnSync("python3", ["--version"]).status === 0;
|
|
46
|
+
|
|
47
|
+
function hermesScan(frame: unknown): { kept: Array<[string, string]>; dropped: number } {
|
|
48
|
+
const script = [
|
|
49
|
+
"import json, sys",
|
|
50
|
+
"import securecoms_transport as t",
|
|
51
|
+
"kept, dropped = t.scan_inbound_attachments(json.loads(sys.stdin.read()))",
|
|
52
|
+
"print(json.dumps({'kept': kept, 'dropped': dropped}))",
|
|
53
|
+
].join("\n");
|
|
54
|
+
const r = spawnSync("python3", ["-c", script], {
|
|
55
|
+
input: JSON.stringify(frame),
|
|
56
|
+
cwd: HERMES_DIR,
|
|
57
|
+
env: { ...process.env, PYTHONPATH: HERMES_DIR },
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
});
|
|
60
|
+
expect(r.status, r.stderr).toBe(0);
|
|
61
|
+
return JSON.parse(r.stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("bridge materializeAttachment -> connector validators", () => {
|
|
65
|
+
for (const trailing of ["", "/"]) {
|
|
66
|
+
it(`a PNG the bridge writes passes both connectors (attachmentsDir trailing "${trailing}")`, async () => {
|
|
67
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
|
|
68
|
+
const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
|
|
69
|
+
expect(meta.mime).toBe("image/png");
|
|
70
|
+
const frame = { channel_id: CH, attachments: [meta] };
|
|
71
|
+
|
|
72
|
+
expect(inboundAttachmentsOf(frame)).toEqual([{ path: meta.path, mime: "image/png", filename: "IMG_3375.png" }]);
|
|
73
|
+
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.skipIf(!HAS_PYTHON3)(`... and the Hermes validator (attachmentsDir trailing "${trailing}")`, async () => {
|
|
77
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
|
|
78
|
+
const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
|
|
79
|
+
expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [[meta.path, "image/png"]], dropped: 0 });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it("a non-image the bridge saves is dropped by both connectors", async () => {
|
|
84
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
|
|
85
|
+
const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
|
|
86
|
+
expect(meta.mime).toBe("application/octet-stream");
|
|
87
|
+
const frame = { channel_id: CH, attachments: [meta] };
|
|
88
|
+
expect(inboundAttachmentsOf(frame)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.skipIf(!HAS_PYTHON3)("... and the Hermes validator drops it too", async () => {
|
|
92
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
|
|
93
|
+
const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
|
|
94
|
+
expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [], dropped: 1 });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// fake fetch for /healthz, /v1/reply-receipt, /v1/send and /v1/status.
|
|
19
19
|
|
|
20
20
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { closeSync, constants as fsConstants, mkdirSync, mkdtempSync, openSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
22
23
|
import { tmpdir } from "node:os";
|
|
23
24
|
import { join } from "node:path";
|
|
24
25
|
import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
|
|
@@ -589,4 +590,230 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
|
|
|
589
590
|
await waitFor(() => acksOf(instances[0]).includes("self"));
|
|
590
591
|
expect(rt.dispatches).toHaveLength(0);
|
|
591
592
|
});
|
|
593
|
+
|
|
594
|
+
// ------------------------------------------------- image attachments ----
|
|
595
|
+
// The daemon decrypts an artifact_share to <attachmentsDir>/<channel_id>/
|
|
596
|
+
// and puts {filename, mime, size, path, sha256} on the frame. OpenClaw reads
|
|
597
|
+
// inbound media from the ctx fields its plugin-sdk buildAgentMediaPayload
|
|
598
|
+
// emits (MediaPath/MediaType/MediaUrl + MediaPaths/MediaUrls/MediaTypes), and
|
|
599
|
+
// only from allowed local roots; MediaWorkspaceDir adds the file's own dir.
|
|
600
|
+
// The connector checks the REAL file: images only, by magic number, <= 5 MiB.
|
|
601
|
+
|
|
602
|
+
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
|
603
|
+
const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46, 0, 1]);
|
|
604
|
+
const GIF = new TextEncoder().encode("GIF89a\x01\x00\x01\x00\x00\x00");
|
|
605
|
+
const WEBP = new TextEncoder().encode("RIFF\x10\x00\x00\x00WEBPVP8 ");
|
|
606
|
+
let attDir: string;
|
|
607
|
+
beforeEach(() => {
|
|
608
|
+
attDir = join(mkdtempSync(join(tmpdir(), "sc-att-")), "attachments", "chan-img");
|
|
609
|
+
mkdirSync(attDir, { recursive: true });
|
|
610
|
+
});
|
|
611
|
+
const put = (name: string, bytes: Uint8Array, dir = attDir) => {
|
|
612
|
+
mkdirSync(dir, { recursive: true });
|
|
613
|
+
const p = join(dir, `m-${name}`);
|
|
614
|
+
writeFileSync(p, bytes);
|
|
615
|
+
return p;
|
|
616
|
+
};
|
|
617
|
+
const att = (name: string, bytes: Uint8Array, mime: string, dir?: string) => ({
|
|
618
|
+
filename: name, mime, size: bytes.length, path: put(name, bytes, dir), sha256: "abc",
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("an image frame sets OpenClaw's inbound media fields and keeps the caption", async () => {
|
|
622
|
+
const rt = makeRuntime();
|
|
623
|
+
const { instances } = await start(rt.runtime);
|
|
624
|
+
const a = att("IMG_3375.jpeg", JPEG, "image/jpeg");
|
|
625
|
+
deliver(instances[0], {
|
|
626
|
+
channel_id: "chan-img", message_id: "img-1", sender_id: OWNER_ID, text: "what is this?",
|
|
627
|
+
authorization: auth("owner_dm"), attachments: [a],
|
|
628
|
+
});
|
|
629
|
+
await waitFor(() => acksOf(instances[0]).includes("img-1"));
|
|
630
|
+
const [c] = rt.contexts;
|
|
631
|
+
expect(c.MediaPath).toBe(a.path);
|
|
632
|
+
expect(c.MediaUrl).toBe(a.path);
|
|
633
|
+
expect(c.MediaType).toBe("image/jpeg");
|
|
634
|
+
expect(c.MediaPaths).toEqual([a.path]);
|
|
635
|
+
expect(c.MediaUrls).toEqual([a.path]);
|
|
636
|
+
expect(c.MediaTypes).toEqual(["image/jpeg"]);
|
|
637
|
+
expect(c.MediaWorkspaceDir).toBe(attDir);
|
|
638
|
+
expect(c.RawBody).toBe("what is this?");
|
|
639
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
640
|
+
expect(logs.some((l) => l.includes("attachments dropped"))).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("an image with no caption still runs the turn, with a media placeholder body", async () => {
|
|
644
|
+
const rt = makeRuntime();
|
|
645
|
+
const { instances } = await start(rt.runtime);
|
|
646
|
+
const a = att("a.png", PNG, "image/png");
|
|
647
|
+
deliver(instances[0], {
|
|
648
|
+
channel_id: "chan-img", message_id: "img-2", sender_id: OWNER_ID, text: "",
|
|
649
|
+
authorization: auth("owner_dm"), attachments: [a],
|
|
650
|
+
});
|
|
651
|
+
await waitFor(() => acksOf(instances[0]).includes("img-2"));
|
|
652
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
653
|
+
const [c] = rt.contexts;
|
|
654
|
+
expect(c.MediaPaths).toEqual([a.path]);
|
|
655
|
+
expect(c.RawBody).toBe("<media:image>");
|
|
656
|
+
expect(rt.envelopes[0].body).toBe("<media:image>");
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("an ungranted sender's image runs without tools or command authority", async () => {
|
|
660
|
+
const rt = makeRuntime();
|
|
661
|
+
const { instances } = await start(rt.runtime);
|
|
662
|
+
const a = att("r.png", PNG, "image/png");
|
|
663
|
+
deliver(instances[0], {
|
|
664
|
+
channel_id: "chan-img", message_id: "img-u", sender_id: MEMBER_ID, text: "",
|
|
665
|
+
authorization: auth("team_room"), attachments: [a],
|
|
666
|
+
});
|
|
667
|
+
await waitFor(() => acksOf(instances[0]).includes("img-u"));
|
|
668
|
+
const mod = await import("./openclaw-entry.js");
|
|
669
|
+
const [c] = rt.contexts;
|
|
670
|
+
expect(c.CommandAuthorized).toBe(false);
|
|
671
|
+
expect(c.GroupSystemPrompt).toContain(mod.NO_TOOLS_TURN_NOTE);
|
|
672
|
+
expect(c.MediaPaths).toEqual([a.path]);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("accepts each allowed image type by its signature, reporting the sniffed lowercase mime", async () => {
|
|
676
|
+
const mod = await import("./openclaw-entry.js");
|
|
677
|
+
const frame = (attachments: unknown) => ({ channel_id: "chan-img", attachments });
|
|
678
|
+
for (const [name, bytes, mime] of [["a.png", PNG, "image/png"], ["b.jpg", JPEG, "image/jpeg"], ["c.gif", GIF, "image/gif"], ["d.webp", WEBP, "image/webp"]] as const) {
|
|
679
|
+
const a = att(name, bytes, mime.toUpperCase());
|
|
680
|
+
expect(mod.inboundAttachmentsOf(frame([a]))).toEqual([{ path: a.path, mime, filename: name }]);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("drops non-images, a mime mismatch, an unknown signature and an over-cap file", async () => {
|
|
685
|
+
const mod = await import("./openclaw-entry.js");
|
|
686
|
+
const frame = (attachments: unknown) => ({ channel_id: "chan-img", attachments });
|
|
687
|
+
const big = new Uint8Array(mod.MAX_INBOUND_ATTACHMENT_BYTES + 1);
|
|
688
|
+
big.set(PNG);
|
|
689
|
+
const bad = [
|
|
690
|
+
att("v.mp3", Uint8Array.from([0x49, 0x44, 0x33, 4, 0, 0, 0, 0, 0, 0, 0, 0]), "audio/mpeg"),
|
|
691
|
+
att("v.mp4", Uint8Array.from([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32]), "video/mp4"),
|
|
692
|
+
att("d.pdf", new TextEncoder().encode("%PDF-1.7\n%...."), "application/pdf"),
|
|
693
|
+
att("x.svg", new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'><script/></svg>"), "image/svg+xml"),
|
|
694
|
+
att("png-as.jpg", PNG, "image/jpeg"),
|
|
695
|
+
att("t.png", new TextEncoder().encode("just some text"), "image/png"),
|
|
696
|
+
att("big.png", big, "image/png"),
|
|
697
|
+
];
|
|
698
|
+
for (const b of bad) expect(mod.inboundAttachmentsOf(frame([b]))).toEqual([]);
|
|
699
|
+
expect(mod.scanInboundAttachments(frame(bad))).toEqual({ kept: [], dropped: bad.length });
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("drops a bad path, a control or bidi character, and caps the count", async () => {
|
|
703
|
+
const mod = await import("./openclaw-entry.js");
|
|
704
|
+
const good = att("ok.jpg", JPEG, "image/jpeg");
|
|
705
|
+
const frame = (attachments: unknown) => ({ channel_id: "chan-img", attachments });
|
|
706
|
+
expect(mod.inboundAttachmentsOf(frame([good]))).toEqual([{ path: good.path, mime: "image/jpeg", filename: "ok.jpg" }]);
|
|
707
|
+
const outside = put("id.jpg", JPEG, join(attDir, "..", "..", "secrets", "chan-img"));
|
|
708
|
+
const other = put("o.jpg", JPEG, join(attDir, "..", "other-chan"));
|
|
709
|
+
const bad = [
|
|
710
|
+
{ ...good, path: "relative/attachments/chan-img/m-ok.jpg" },
|
|
711
|
+
{ ...good, path: `${attDir}/../chan-img/m-ok.jpg` },
|
|
712
|
+
{ ...good, path: `${attDir}/./m-ok.jpg` },
|
|
713
|
+
{ ...good, path: outside }, // not under attachments/
|
|
714
|
+
{ ...good, path: other }, // another channel's dir
|
|
715
|
+
{ ...good, path: `${attDir}/x\u0000.jpg` },
|
|
716
|
+
{ ...good, path: put("rtlgpj.jpg", JPEG) },
|
|
717
|
+
{ ...good, path: put("nl\n.jpg", JPEG) },
|
|
718
|
+
{ ...good, filename: "invfdp.jpg" },
|
|
719
|
+
{ ...good, filename: 5 },
|
|
720
|
+
{ ...good, path: 42 },
|
|
721
|
+
{ ...good, mime: 7 },
|
|
722
|
+
null,
|
|
723
|
+
"string",
|
|
724
|
+
];
|
|
725
|
+
for (const b of bad) expect(mod.inboundAttachmentsOf(frame([b]))).toEqual([]);
|
|
726
|
+
expect(mod.inboundAttachmentsOf(frame([...bad, good]))).toHaveLength(1);
|
|
727
|
+
// Every attachment must share one dir (the frame's channel dir).
|
|
728
|
+
const elsewhere = att("y.jpg", JPEG, "image/jpeg", join(mkdtempSync(join(tmpdir(), "sc-att2-")), "attachments", "chan-img"));
|
|
729
|
+
expect(mod.inboundAttachmentsOf(frame([good, elsewhere])).map((a: any) => a.path)).toEqual([good.path]);
|
|
730
|
+
// Cap.
|
|
731
|
+
const many = Array.from({ length: 20 }, (_, i) => att(`p${i}.jpg`, JPEG, "image/jpeg"));
|
|
732
|
+
expect(mod.inboundAttachmentsOf(frame(many))).toHaveLength(mod.MAX_INBOUND_ATTACHMENTS);
|
|
733
|
+
expect(mod.MAX_INBOUND_ATTACHMENTS).toBe(8);
|
|
734
|
+
expect(mod.MAX_INBOUND_ATTACHMENT_BYTES).toBe(5 * 1024 * 1024);
|
|
735
|
+
// Not an array / no channel.
|
|
736
|
+
expect(mod.inboundAttachmentsOf({ channel_id: "chan-img", attachments: good })).toEqual([]);
|
|
737
|
+
expect(mod.inboundAttachmentsOf({ attachments: [good] })).toEqual([]);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
/** inboundAttachmentsOf in a fresh node process (own cwd, hard timeout). */
|
|
741
|
+
function scanInChild(frame: unknown, cwd?: string) {
|
|
742
|
+
const entry = join(__dirname, "openclaw-entry.js");
|
|
743
|
+
const code = `import(${JSON.stringify(entry)}).then((m) => { process.stdout.write(JSON.stringify(m.inboundAttachmentsOf(${JSON.stringify(frame)}))); process.exit(0); });`;
|
|
744
|
+
const r = spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8", timeout: 15_000, cwd });
|
|
745
|
+
expect(r.signal, "child timed out (blocked open?)").toBeNull();
|
|
746
|
+
expect(r.status, r.stderr).toBe(0);
|
|
747
|
+
return JSON.parse(r.stdout);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
it("guards that only normalize / isAbsolute / isFile catch (review r2 mutation survivors)", async () => {
|
|
751
|
+
const mod = await import("./openclaw-entry.js");
|
|
752
|
+
const frame = (attachments: unknown) => ({ channel_id: "chan-img", attachments });
|
|
753
|
+
const root = join(attDir, "..", "..");
|
|
754
|
+
// A real image reachable through an UNnormalized path whose literal parent
|
|
755
|
+
// is .../attachments/chan-img: only the normalize() equality rejects it.
|
|
756
|
+
put("f.png", PNG, join(root, "y", "attachments", "chan-img"));
|
|
757
|
+
const dotted = `${root}/attachments/chan-img/../../y/attachments/chan-img/m-f.png`;
|
|
758
|
+
expect(mod.inboundAttachmentsOf(frame([{ path: dotted, mime: "image/png" }]))).toEqual([]);
|
|
759
|
+
// A relative path that resolves to a real image from the cwd: only
|
|
760
|
+
// isAbsolute rejects it (run in a child: workers cannot chdir).
|
|
761
|
+
put("rel.png", PNG);
|
|
762
|
+
expect(scanInChild(frame([{ path: "attachments/chan-img/m-rel.png", mime: "image/png" }]), root)).toEqual([]);
|
|
763
|
+
// A FIFO that holds PNG bytes (a writer keeps it open): only the
|
|
764
|
+
// regular-file check rejects it.
|
|
765
|
+
const fifo = join(attDir, "m-fifo.png");
|
|
766
|
+
expect(spawnSync("mkfifo", [fifo]).status).toBe(0);
|
|
767
|
+
const holder = openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK);
|
|
768
|
+
try {
|
|
769
|
+
writeSync(holder, PNG);
|
|
770
|
+
expect(mod.inboundAttachmentsOf(frame([{ path: fifo, mime: "image/png" }]))).toEqual([]);
|
|
771
|
+
} finally {
|
|
772
|
+
closeSync(holder);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("a FIFO with no writer is dropped without hanging (O_NONBLOCK)", () => {
|
|
777
|
+
const fifo = join(attDir, "m-hang.png");
|
|
778
|
+
expect(spawnSync("mkfifo", [fifo]).status).toBe(0);
|
|
779
|
+
// In a child with a timeout: without O_NONBLOCK, open() blocks forever.
|
|
780
|
+
expect(scanInChild({ channel_id: "chan-img", attachments: [{ path: fifo, mime: "image/png" }] })).toEqual([]);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("a symlinked attachment is not followed", async () => {
|
|
784
|
+
const mod = await import("./openclaw-entry.js");
|
|
785
|
+
const target = put("real.png", PNG, join(attDir, "..", "..", "elsewhere"));
|
|
786
|
+
const link = join(attDir, "m-link.png");
|
|
787
|
+
symlinkSync(target, link);
|
|
788
|
+
expect(mod.inboundAttachmentsOf({ channel_id: "chan-img", attachments: [{ path: link, mime: "image/png" }] })).toEqual([]);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("a frame whose only attachment is invalid sets no media fields and logs a content-free drop count", async () => {
|
|
792
|
+
const rt = makeRuntime();
|
|
793
|
+
const { instances } = await start(rt.runtime);
|
|
794
|
+
deliver(instances[0], {
|
|
795
|
+
channel_id: "chan-img", message_id: "img-3", sender_id: OWNER_ID, text: "look",
|
|
796
|
+
authorization: auth("owner_dm"), attachments: [{ ...att("x.jpg", JPEG, "image/jpeg"), path: "/etc/passwd" }],
|
|
797
|
+
});
|
|
798
|
+
await waitFor(() => acksOf(instances[0]).includes("img-3"));
|
|
799
|
+
const [c] = rt.contexts;
|
|
800
|
+
for (const k of ["MediaPath", "MediaPaths", "MediaType", "MediaTypes", "MediaUrl", "MediaUrls", "MediaWorkspaceDir"]) {
|
|
801
|
+
expect(k in c).toBe(false);
|
|
802
|
+
}
|
|
803
|
+
expect(c.RawBody).toBe("look");
|
|
804
|
+
const line = logs.find((l) => l.includes("attachments dropped"));
|
|
805
|
+
expect(line).toBe("[securecoms] inbound attachments dropped=1 kept=0");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("a text-only frame is unchanged: no media fields, same body", async () => {
|
|
809
|
+
const rt = makeRuntime();
|
|
810
|
+
const { instances } = await start(rt.runtime);
|
|
811
|
+
deliver(instances[0], { channel_id: "chan-t", message_id: "t-1", sender_id: OWNER_ID, text: "hello", authorization: auth("owner_dm") });
|
|
812
|
+
await waitFor(() => acksOf(instances[0]).includes("t-1"));
|
|
813
|
+
const [c] = rt.contexts;
|
|
814
|
+
expect(Object.keys(c).filter((k) => k.startsWith("Media"))).toEqual([]);
|
|
815
|
+
expect(c.RawBody).toBe("hello");
|
|
816
|
+
expect(c.CommandBody).toBe("hello");
|
|
817
|
+
expect(rt.envelopes[0].body).toBe("hello");
|
|
818
|
+
});
|
|
592
819
|
});
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
* from a file.
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
-
import { readFileSync } from "node:fs";
|
|
54
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
55
55
|
import { homedir } from "node:os";
|
|
56
|
-
import { join } from "node:path";
|
|
56
|
+
import { basename, dirname, isAbsolute, join, normalize } from "node:path";
|
|
57
57
|
import { evaluateGate, resolveBoundIdentity, backoffDelayMs } from "./gate.js";
|
|
58
58
|
|
|
59
59
|
// Runtime injected by register() — used by the inbound handler.
|
|
@@ -104,6 +104,119 @@ export function voiceGuidanceOf(frame) {
|
|
|
104
104
|
const g = frame.guidance.trim().slice(0, VOICE_GUIDANCE_MAX_CHARS);
|
|
105
105
|
return g || undefined;
|
|
106
106
|
}
|
|
107
|
+
// Inbound attachments. For an artifact_share the daemon decrypts the file to
|
|
108
|
+
// <attachmentsDir>/<channel_id>/<message_id>-<name> and puts
|
|
109
|
+
// {filename, mime, size, path, sha256} on the frame. The frame's fields are
|
|
110
|
+
// claims; only a file that passes ALL of these reaches OpenClaw:
|
|
111
|
+
// - path: a string, absolute and already normalized (normalize() collapses
|
|
112
|
+
// any "." or ".." segment, so an unnormalized path, including any ".."
|
|
113
|
+
// traversal, fails the equality), no C0/C1 control or bidi character,
|
|
114
|
+
// parent dir == the frame's channel id inside a dir named "attachments",
|
|
115
|
+
// every kept file in one dir;
|
|
116
|
+
// - filename (when present): no control or bidi character;
|
|
117
|
+
// - the file itself (opened O_NOFOLLOW|O_NONBLOCK): a regular file of at most
|
|
118
|
+
// MAX_INBOUND_ATTACHMENT_BYTES whose first bytes are a PNG, JPEG, GIF or
|
|
119
|
+
// WEBP signature, and the claimed mime equals that sniffed type.
|
|
120
|
+
// Images only: no SVG (script), audio, video or documents. Anything else is
|
|
121
|
+
// dropped; at most MAX_INBOUND_ATTACHMENTS are kept.
|
|
122
|
+
export const MAX_INBOUND_ATTACHMENTS = 8;
|
|
123
|
+
export const MAX_INBOUND_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
124
|
+
const FORBIDDEN_CHARS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
|
125
|
+
|
|
126
|
+
/** The image type from a file's leading bytes, or undefined. */
|
|
127
|
+
export function sniffImageMime(b) {
|
|
128
|
+
if (!b || b.length < 3) return undefined;
|
|
129
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
|
|
130
|
+
b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return "image/png";
|
|
131
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
|
|
132
|
+
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 &&
|
|
133
|
+
(b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return "image/gif";
|
|
134
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
135
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The sniffed image mime of a regular file within the size cap, else undefined. */
|
|
140
|
+
function _sniffFile(path) {
|
|
141
|
+
let fd;
|
|
142
|
+
try {
|
|
143
|
+
// O_NONBLOCK: a FIFO planted at the path must not hang the gateway in
|
|
144
|
+
// open(); fstat below then drops it as not a regular file.
|
|
145
|
+
fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
146
|
+
const st = fstatSync(fd);
|
|
147
|
+
if (!st.isFile() || st.size > MAX_INBOUND_ATTACHMENT_BYTES) return undefined;
|
|
148
|
+
const head = Buffer.alloc(12);
|
|
149
|
+
const n = readSync(fd, head, 0, 12, 0);
|
|
150
|
+
return sniffImageMime(head.subarray(0, n));
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
} finally {
|
|
154
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Validate a frame's attachments: { kept: [{path, mime, filename?}], dropped }. */
|
|
159
|
+
export function scanInboundAttachments(frame) {
|
|
160
|
+
const raw = frame?.attachments;
|
|
161
|
+
const channelId = frame?.channel_id;
|
|
162
|
+
if (!Array.isArray(raw)) return { kept: [], dropped: 0 };
|
|
163
|
+
if (typeof channelId !== "string" || channelId === "") return { kept: [], dropped: raw.length };
|
|
164
|
+
const kept = [];
|
|
165
|
+
let dir;
|
|
166
|
+
for (const a of raw) {
|
|
167
|
+
if (kept.length >= MAX_INBOUND_ATTACHMENTS) break;
|
|
168
|
+
if (a === null || typeof a !== "object") continue;
|
|
169
|
+
const { path, mime, filename } = a;
|
|
170
|
+
if (typeof path !== "string" || typeof mime !== "string") continue;
|
|
171
|
+
if (filename !== undefined && (typeof filename !== "string" || FORBIDDEN_CHARS.test(filename))) continue;
|
|
172
|
+
if (FORBIDDEN_CHARS.test(path) || !isAbsolute(path) || normalize(path) !== path) continue;
|
|
173
|
+
const parent = dirname(path);
|
|
174
|
+
if (basename(parent) !== channelId || basename(dirname(parent)) !== "attachments") continue;
|
|
175
|
+
if (dir !== undefined && parent !== dir) continue;
|
|
176
|
+
const sniffed = _sniffFile(path);
|
|
177
|
+
if (sniffed === undefined || sniffed !== mime.toLowerCase()) continue;
|
|
178
|
+
dir ??= parent;
|
|
179
|
+
kept.push({ path, mime: sniffed, ...(typeof filename === "string" ? { filename } : {}) });
|
|
180
|
+
}
|
|
181
|
+
return { kept, dropped: raw.length - kept.length };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The attachments of a frame that pass every check (see above). */
|
|
185
|
+
export function inboundAttachmentsOf(frame) {
|
|
186
|
+
return scanInboundAttachments(frame).kept;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// OpenClaw's own placeholder body for a media-only message (as its bundled
|
|
190
|
+
// channels use), so a turn with no caption still has a body.
|
|
191
|
+
function mediaPlaceholder(mime) {
|
|
192
|
+
const kind = mime.split("/", 1)[0];
|
|
193
|
+
if (kind === "image" || kind === "video" || kind === "audio") return `<media:${kind}>`;
|
|
194
|
+
return "<media:document>";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The inbound media ctx fields, in the shape of OpenClaw's plugin-sdk
|
|
199
|
+
* buildAgentMediaPayload (src/plugin-sdk/agent-media-payload.ts). OpenClaw
|
|
200
|
+
* reads a local media file only inside its allowed roots (its own media/,
|
|
201
|
+
* workspace, tmp dirs) plus ctx.MediaWorkspaceDir, the same field its webchat
|
|
202
|
+
* sets for offloaded files. Setting it to the attachments' own per-channel dir
|
|
203
|
+
* lets OpenClaw read exactly these files without a config change or a copy.
|
|
204
|
+
*/
|
|
205
|
+
export function inboundMediaFields(attachments) {
|
|
206
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return {};
|
|
207
|
+
const paths = attachments.map((a) => a.path);
|
|
208
|
+
const types = attachments.map((a) => a.mime);
|
|
209
|
+
return {
|
|
210
|
+
MediaPath: paths[0],
|
|
211
|
+
MediaType: types[0],
|
|
212
|
+
MediaUrl: paths[0],
|
|
213
|
+
MediaPaths: paths,
|
|
214
|
+
MediaUrls: paths,
|
|
215
|
+
MediaTypes: types,
|
|
216
|
+
MediaWorkspaceDir: dirname(paths[0]),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
107
220
|
const SEEN_MAX = 5000;
|
|
108
221
|
|
|
109
222
|
/** Thrown by the inbound handler when register() has not injected a runtime. */
|
|
@@ -671,6 +784,11 @@ export class LoopbackChannel {
|
|
|
671
784
|
this._ack(messageId);
|
|
672
785
|
return;
|
|
673
786
|
}
|
|
787
|
+
const { kept: attachments, dropped } = scanInboundAttachments(frame);
|
|
788
|
+
if (dropped > 0) {
|
|
789
|
+
// Content-free: counts only, never a path, name or mime.
|
|
790
|
+
this._logSafe("[securecoms] inbound attachments dropped={d} kept={k}", { d: dropped, k: attachments.length });
|
|
791
|
+
}
|
|
674
792
|
await this.onMessage?.(typeof frame.text === "string" ? frame.text : "", {
|
|
675
793
|
conversationId: frame.channel_id,
|
|
676
794
|
messageId,
|
|
@@ -679,6 +797,7 @@ export class LoopbackChannel {
|
|
|
679
797
|
authorization: frame.authorization,
|
|
680
798
|
timestamp: frame.ts,
|
|
681
799
|
voiceGuidance: voiceGuidanceOf(frame),
|
|
800
|
+
attachments,
|
|
682
801
|
});
|
|
683
802
|
this._failures.delete(messageId);
|
|
684
803
|
this._failStreak = 0;
|
|
@@ -839,9 +958,14 @@ export class LoopbackChannel {
|
|
|
839
958
|
// SecureComs channel the message arrived on (closure over metadata).
|
|
840
959
|
// ---------------------------------------------------------------------------
|
|
841
960
|
|
|
842
|
-
async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
961
|
+
async function _handleInbound({ text: rawText, metadata, channel, account, cfg }) {
|
|
843
962
|
const core = _runtime;
|
|
844
963
|
if (!core) throw new RuntimeNotReadyError();
|
|
964
|
+
const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : [];
|
|
965
|
+
// An image with no caption still runs the turn: give it OpenClaw's media
|
|
966
|
+
// placeholder body rather than an empty one.
|
|
967
|
+
const caption = typeof rawText === "string" ? rawText : "";
|
|
968
|
+
const text = caption.trim() === "" && attachments.length > 0 ? mediaPlaceholder(attachments[0].mime) : caption;
|
|
845
969
|
|
|
846
970
|
const conv = metadata.conversationId;
|
|
847
971
|
if (typeof conv !== "string" || conv === "") {
|
|
@@ -941,6 +1065,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
|
941
1065
|
// this turn's extra system prompt (get-reply's extraSystemPromptParts)
|
|
942
1066
|
// for any chat type.
|
|
943
1067
|
...(extraPrompt ? { GroupSystemPrompt: extraPrompt } : {}),
|
|
1068
|
+
...inboundMediaFields(attachments),
|
|
944
1069
|
});
|
|
945
1070
|
|
|
946
1071
|
await core.channel.session.recordInboundSession({
|