Skip to content

Commit b43d73b

Browse files
committed
fix: persist generated reply media before delivery
1 parent 05d351c commit b43d73b

3 files changed

Lines changed: 76 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ Docs: https://docs.openclaw.ai
195195
- Windows/restart: clean up stale gateway listeners before Windows self-restart and treat listener and argv probe failures as inconclusive, so scheduled-task relaunch no longer falls into an `EADDRINUSE` retry loop. (#60480) Thanks @arifahmedjoy.
196196
- Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.
197197
- Agents/video generation: accept `agents.defaults.videoGenerationModel` in strict config validation and `openclaw config set/get`, so gateways using `video_generate` no longer fail to boot after enabling a video model.
198+
- Discord/image generation: persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop failing with missing local workspace paths.
198199

199200
## 2026.4.2
200201

src/auto-reply/reply/reply-media-paths.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import path from "node:path";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn());
5+
const saveMediaSource = vi.hoisted(() => vi.fn());
56

67
vi.mock("../../agents/sandbox.js", () => ({
78
ensureSandboxWorkspaceForSession,
89
}));
910

11+
vi.mock("../../media/store.js", () => ({
12+
saveMediaSource,
13+
}));
14+
1015
import { createReplyMediaPathNormalizer } from "./reply-media-paths.js";
1116

1217
describe("createReplyMediaPathNormalizer", () => {
1318
beforeEach(() => {
1419
ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null);
20+
saveMediaSource.mockReset();
1521
});
1622

1723
it("resolves workspace-relative media against the agent workspace", async () => {
@@ -74,6 +80,7 @@ describe("createReplyMediaPathNormalizer", () => {
7480
mediaUrl: "/Users/peter/.openclaw/media/inbound/photo.png",
7581
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
7682
});
83+
expect(saveMediaSource).not.toHaveBeenCalled();
7784
});
7885

7986
it("keeps sandbox media strict when workspaceOnly is enabled", async () => {
@@ -93,4 +100,31 @@ describe("createReplyMediaPathNormalizer", () => {
93100
}),
94101
).rejects.toThrow(/sandbox root|outside|escapes/i);
95102
});
103+
104+
it("persists volatile agent-state media from the workspace into host outbound media", async () => {
105+
saveMediaSource.mockResolvedValue({
106+
path: "/Users/peter/.openclaw/media/outbound/persisted.png",
107+
});
108+
const normalize = createReplyMediaPathNormalizer({
109+
cfg: {},
110+
sessionKey: "session-key",
111+
workspaceDir: "/Users/peter/.openclaw/workspace",
112+
});
113+
114+
const result = await normalize({
115+
mediaUrls: [
116+
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
117+
],
118+
});
119+
120+
expect(saveMediaSource).toHaveBeenCalledWith(
121+
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
122+
undefined,
123+
"outbound",
124+
);
125+
expect(result).toMatchObject({
126+
mediaUrl: "/Users/peter/.openclaw/media/outbound/persisted.png",
127+
mediaUrls: ["/Users/peter/.openclaw/media/outbound/persisted.png"],
128+
});
129+
});
96130
});

src/auto-reply/reply/reply-media-paths.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1+
import path from "node:path";
12
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
23
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
34
import { resolvePathFromInput } from "../../agents/path-policy.js";
45
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
56
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
67
import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy.js";
78
import type { OpenClawConfig } from "../../config/config.js";
9+
import { logVerbose } from "../../globals.js";
10+
import { saveMediaSource } from "../../media/store.js";
811
import type { ReplyPayload } from "../types.js";
912

1013
const HTTP_URL_RE = /^https?:\/\//i;
1114
const FILE_URL_RE = /^file:\/\//i;
1215
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
1316
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1417
const HAS_FILE_EXT_RE = /\.\w{1,10}$/;
18+
const AGENT_STATE_MEDIA_DIRNAME = path.join(".openclaw", "media");
19+
20+
function isPathInside(root: string, candidate: string): boolean {
21+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
22+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
23+
}
1524

1625
function isLikelyLocalMediaSource(media: string): boolean {
1726
return (
@@ -44,6 +53,7 @@ export function createReplyMediaPathNormalizer(params: {
4453
agentId,
4554
});
4655
let sandboxRootPromise: Promise<string | undefined> | undefined;
56+
const persistedMediaBySource = new Map<string, Promise<string>>();
4757

4858
const resolveSandboxRoot = async (): Promise<string | undefined> => {
4959
if (!sandboxRootPromise) {
@@ -56,6 +66,36 @@ export function createReplyMediaPathNormalizer(params: {
5666
return await sandboxRootPromise;
5767
};
5868

69+
const persistVolatileAgentMedia = async (media: string): Promise<string> => {
70+
if (!path.isAbsolute(media)) {
71+
return media;
72+
}
73+
const sandboxRoot = await resolveSandboxRoot();
74+
const volatileRoots = [params.workspaceDir, sandboxRoot]
75+
.filter((root): root is string => Boolean(root))
76+
.map((root) => path.join(path.resolve(root), AGENT_STATE_MEDIA_DIRNAME));
77+
if (!volatileRoots.some((root) => isPathInside(root, media))) {
78+
return media;
79+
}
80+
const cached = persistedMediaBySource.get(media);
81+
if (cached) {
82+
return await cached;
83+
}
84+
const persistPromise = saveMediaSource(media, undefined, "outbound")
85+
.then((saved) => saved.path)
86+
.catch((err) => {
87+
persistedMediaBySource.delete(media);
88+
throw err;
89+
});
90+
persistedMediaBySource.set(media, persistPromise);
91+
try {
92+
return await persistPromise;
93+
} catch (err) {
94+
logVerbose(`failed to persist volatile reply media ${media}: ${String(err)}`);
95+
return media;
96+
}
97+
};
98+
5999
const normalizeMediaSource = async (raw: string): Promise<string> => {
60100
const media = raw.trim();
61101
if (!media) {
@@ -100,7 +140,7 @@ export function createReplyMediaPathNormalizer(params: {
100140
const normalizedMedia: string[] = [];
101141
const seen = new Set<string>();
102142
for (const media of mediaList) {
103-
const normalized = await normalizeMediaSource(media);
143+
const normalized = await persistVolatileAgentMedia(await normalizeMediaSource(media));
104144
if (!normalized || seen.has(normalized)) {
105145
continue;
106146
}

0 commit comments

Comments
 (0)