@securecoms/agent-bridge 0.37.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.
@@ -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("rtl‮gpj.jpg")["path"]},
670
+ {**good, "path": self._att("nl\n.jpg")["path"]},
671
+ {**good, "filename": "inv‮fdp.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("rtl‮gpj.jpg", JPEG) },
717
+ { ...good, path: put("nl\n.jpg", JPEG) },
718
+ { ...good, filename: "inv‮fdp.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({