fix(media): resolve relative MEDIA paths against agent workspace (#58624)

* fix(media): resolve relative MEDIA: paths against agent workspace dir

* fix(agents): remove stale ollama compat import

* fix(media): preserve workspace dir in outbound access
This commit is contained in:
Kenny Xie
2026-03-31 18:09:28 -07:00
committed by GitHub
parent 0b3d31c0ce
commit 2650ce31fc
6 changed files with 90 additions and 3 deletions

View File

@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
isOllamaCompatProvider,
resolveOllamaBaseUrlForRun,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,

View File

@@ -3,6 +3,8 @@ export type OutboundMediaReadFile = (filePath: string) => Promise<Buffer>;
export type OutboundMediaAccess = {
localRoots?: readonly string[];
readFile?: OutboundMediaReadFile;
/** Agent workspace directory for resolving relative MEDIA: paths. */
workspaceDir?: string;
};
export type OutboundMediaLoadParams = {
@@ -11,6 +13,8 @@ export type OutboundMediaLoadParams = {
mediaLocalRoots?: readonly string[];
mediaReadFile?: OutboundMediaReadFile;
optimizeImages?: boolean;
/** Agent workspace directory for resolving relative MEDIA: paths. */
workspaceDir?: string;
};
export type OutboundMediaLoadOptions = {
@@ -19,6 +23,8 @@ export type OutboundMediaLoadOptions = {
readFile?: (filePath: string) => Promise<Buffer>;
hostReadCapability?: boolean;
optimizeImages?: boolean;
/** Agent workspace directory for resolving relative MEDIA: paths. */
workspaceDir?: string;
};
export function resolveOutboundMediaLocalRoots(
@@ -38,12 +44,14 @@ export function resolveOutboundMediaAccess(
params.mediaAccess?.localRoots ?? params.mediaLocalRoots,
);
const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile;
if (!localRoots && !readFile) {
const workspaceDir = params.mediaAccess?.workspaceDir;
if (!localRoots && !readFile && !workspaceDir) {
return undefined;
}
return {
...(localRoots ? { localRoots } : {}),
...(readFile ? { readFile } : {}),
...(workspaceDir ? { workspaceDir } : {}),
};
}
@@ -51,6 +59,7 @@ export function buildOutboundMediaLoadOptions(
params: OutboundMediaLoadParams = {},
): OutboundMediaLoadOptions {
const mediaAccess = resolveOutboundMediaAccess(params);
const workspaceDir = mediaAccess?.workspaceDir ?? params.workspaceDir;
if (mediaAccess?.readFile) {
return {
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
@@ -58,6 +67,7 @@ export function buildOutboundMediaLoadOptions(
readFile: mediaAccess.readFile,
hostReadCapability: true,
...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}),
...(workspaceDir ? { workspaceDir } : {}),
};
}
const localRoots = mediaAccess?.localRoots;
@@ -65,5 +75,6 @@ export function buildOutboundMediaLoadOptions(
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
...(localRoots ? { localRoots } : {}),
...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}),
...(workspaceDir ? { workspaceDir } : {}),
};
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentScopedOutboundMediaAccess } from "./read-capability.js";
describe("resolveAgentScopedOutboundMediaAccess", () => {
it("preserves caller-provided workspaceDir from mediaAccess", () => {
const result = resolveAgentScopedOutboundMediaAccess({
cfg: {} as OpenClawConfig,
mediaAccess: { workspaceDir: "/tmp/media-workspace" },
});
expect(result).toMatchObject({ workspaceDir: "/tmp/media-workspace" });
});
it("prefers explicit workspaceDir over mediaAccess.workspaceDir", () => {
const result = resolveAgentScopedOutboundMediaAccess({
cfg: {} as OpenClawConfig,
workspaceDir: "/tmp/explicit-workspace",
mediaAccess: { workspaceDir: "/tmp/media-workspace" },
});
expect(result).toMatchObject({ workspaceDir: "/tmp/explicit-workspace" });
});
});

View File

@@ -45,16 +45,21 @@ export function resolveAgentScopedOutboundMediaAccess(params: {
agentId: params.agentId,
mediaSources: params.mediaSources,
});
const resolvedWorkspaceDir =
params.workspaceDir ??
params.mediaAccess?.workspaceDir ??
(params.agentId ? resolveAgentWorkspaceDir(params.cfg, params.agentId) : undefined);
const readFile =
params.mediaAccess?.readFile ??
params.mediaReadFile ??
createAgentScopedHostMediaReadFile({
cfg: params.cfg,
agentId: params.agentId,
workspaceDir: params.workspaceDir,
workspaceDir: resolvedWorkspaceDir,
});
return {
...(localRoots?.length ? { localRoots } : {}),
...(readFile ? { readFile } : {}),
...(resolvedWorkspaceDir ? { workspaceDir: resolvedWorkspaceDir } : {}),
};
}

View File

@@ -133,4 +133,42 @@ describe("loadWebMedia", () => {
] as const)("$name", async (testCase) => {
await expectRejectedWebMediaWithoutFilesystemAccess(testCase);
});
describe("workspaceDir relative path resolution", () => {
it("resolves a bare filename against workspaceDir", async () => {
const result = await loadWebMedia("tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("resolves a dot-relative path against workspaceDir", async () => {
const result = await loadWebMedia("./tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("resolves a MEDIA:-prefixed relative path against workspaceDir", async () => {
const result = await loadWebMedia("MEDIA:tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("leaves absolute paths unchanged when workspaceDir is set", async () => {
const result = await loadWebMedia(tinyPngFile, {
...createLocalWebMediaOptions(),
workspaceDir: "/some/other/dir",
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
});
});

View File

@@ -42,6 +42,8 @@ type WebMediaOptions = {
readFile?: (filePath: string) => Promise<Buffer>;
/** Host-local fs-policy read piggyback; rejects plaintext-like document sends. */
hostReadCapability?: boolean;
/** Agent workspace directory for resolving relative MEDIA: paths. */
workspaceDir?: string;
};
function resolveWebMediaOptions(params: {
@@ -221,6 +223,7 @@ async function loadWebMediaInternal(
sandboxValidated = false,
readFile: readFileOverride,
hostReadCapability = false,
workspaceDir,
} = options;
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
// Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png").
@@ -321,6 +324,13 @@ async function loadWebMediaInternal(
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// Resolve relative MEDIA: paths (e.g. "poker_profit.png", "./subdir/file.png")
// against the agent workspace directory so bare filenames written by agents
// are found on disk and pass the local-roots allowlist check.
if (workspaceDir && !path.isAbsolute(mediaUrl)) {
mediaUrl = path.resolve(workspaceDir, mediaUrl);
}
try {
assertNoWindowsNetworkPath(mediaUrl, "Local media path");
} catch (err) {