fix(media): keep local roots configuration-derived (#57770)

* fix(media): keep local roots configuration-derived

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>

* fix(media): simplify local root lookup

* fix(media): keep legacy local roots export
This commit is contained in:
Jacob Tomlinson 2026-03-30 09:15:03 -07:00 committed by GitHub
parent aff6883f93
commit 1ca4261d7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 91 deletions

View File

@ -0,0 +1,37 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMediaToolLocalRoots } from "./media-tool-shared.js";
function normalizeHostPath(value: string): string {
return path.normalize(path.resolve(value));
}
describe("resolveMediaToolLocalRoots", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("does not widen default local roots from media sources", () => {
const stateDir = path.join("/tmp", "openclaw-media-tool-roots-state");
const picturesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
const moviesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const roots = resolveMediaToolLocalRoots(path.join(stateDir, "workspace-agent"), undefined, [
path.join(picturesDir, "photo.png"),
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
"/top-level-file.png",
]);
const normalizedRoots = roots.map(normalizeHostPath);
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace-agent")));
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace")));
expect(normalizedRoots).not.toContain(normalizeHostPath(picturesDir));
expect(normalizedRoots).not.toContain(normalizeHostPath(moviesDir));
expect(normalizedRoots).not.toContain(normalizeHostPath("/"));
});
});

View File

@ -1,6 +1,5 @@
import { type Api, type Model } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import { appendLocalMediaParentRoots } from "../../media/local-roots.js";
import { getDefaultLocalRoots } from "../../media/web-media.js";
import type { ImageModelConfig } from "./image-tool.helpers.js";
import type { ToolModelConfig } from "./model-config.helpers.js";
@ -56,15 +55,14 @@ function applyAgentDefaultModelConfig(
export function resolveMediaToolLocalRoots(
workspaceDirRaw: string | undefined,
options?: { workspaceOnly?: boolean },
mediaSources?: readonly string[],
_mediaSources?: readonly string[],
): string[] {
const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw);
if (options?.workspaceOnly) {
return workspaceDir ? [workspaceDir] : [];
}
const roots = getDefaultLocalRoots();
const scopedRoots = workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots];
return appendLocalMediaParentRoots(scopedRoots, mediaSources);
return workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots];
}
export function resolvePromptAndModelOverride(

View File

@ -2,7 +2,6 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendLocalMediaParentRoots,
getAgentScopedMediaLocalRoots,
getAgentScopedMediaLocalRootsForSources,
getDefaultMediaLocalRoots,
@ -52,6 +51,14 @@ describe("local media roots", () => {
expect(normalizedRoots).not.toContain(picturesRoot);
}
function expectPicturesRootAbsent(roots: readonly string[], picturesRoot?: string) {
expectPicturesRootPresence({
roots,
shouldContainPictures: false,
picturesRoot,
});
}
function expectAgentMediaRootsCase(params: {
stateDir: string;
getRoots: () => readonly string[];
@ -101,38 +108,12 @@ describe("local media roots", () => {
});
});
it("adds concrete parent roots for local media sources without widening to filesystem root", () => {
const picturesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
const moviesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
const roots = appendLocalMediaParentRoots(
["/tmp/base"],
[
path.join(picturesDir, "photo.png"),
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
"https://example.com/remote.png",
"/top-level-file.png",
],
);
expect(roots.map(normalizeHostPath)).toEqual(
expect.arrayContaining([
normalizeHostPath("/tmp/base"),
normalizeHostPath(picturesDir),
normalizeHostPath(moviesDir),
]),
);
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
});
it.each([
{
name: "widens agent media roots for concrete local sources when workspaceOnly is disabled",
name: "does not widen agent media roots for concrete local sources when workspaceOnly is disabled",
stateDir: path.join("/tmp", "openclaw-flexible-media-roots-state"),
cfg: {},
shouldContainPictures: true,
shouldContainPictures: false,
},
{
name: "does not widen agent media roots when workspaceOnly is enabled",
@ -147,7 +128,7 @@ describe("local media roots", () => {
shouldContainPictures: false,
},
{
name: "widens media roots again when messaging-profile agents explicitly enable filesystem tools",
name: "does not widen media roots even when messaging-profile agents explicitly enable filesystem tools",
stateDir: path.join("/tmp", "openclaw-messaging-fs-media-roots-state"),
cfg: {
tools: {
@ -155,7 +136,7 @@ describe("local media roots", () => {
fs: { workspaceOnly: false },
},
},
shouldContainPictures: true,
shouldContainPictures: false,
},
] as const)("$name", ({ stateDir, cfg, shouldContainPictures }) => {
const roots = withStateDir(stateDir, () =>
@ -167,4 +148,33 @@ describe("local media roots", () => {
);
expectPicturesRootPresence({ roots, shouldContainPictures });
});
it("keeps agent-scoped defaults even when mediaSources include file URLs and top-level paths", () => {
const stateDir = path.join("/tmp", "openclaw-file-url-media-roots-state");
const picturesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
const moviesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
const roots = withStateDir(stateDir, () =>
getAgentScopedMediaLocalRootsForSources({
cfg: {},
agentId: "ops",
mediaSources: [
path.join(picturesDir, "photo.png"),
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
"/top-level-file.png",
],
}),
);
expectNormalizedRootsContain(roots, [
path.join(stateDir, "media"),
path.join(stateDir, "workspace"),
path.join(stateDir, "workspace-ops"),
]);
expectPicturesRootAbsent(roots, picturesDir);
expectPicturesRootAbsent(roots, moviesDir);
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
});
});

View File

@ -1,23 +1,14 @@
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
resolveEffectiveToolFsRootExpansionAllowed,
resolveEffectiveToolFsWorkspaceOnly,
} from "../agents/tool-fs-policy.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { resolveUserPath } from "../utils.js";
type BuildMediaLocalRootsOptions = {
preferredTmpDir?: string;
};
let cachedPreferredTmpDir: string | undefined;
const HTTP_URL_RE = /^https?:\/\//i;
const DATA_URL_RE = /^data:/i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
function resolveCachedPreferredTmpDir(): string {
if (!cachedPreferredTmpDir) {
@ -63,60 +54,24 @@ export function getAgentScopedMediaLocalRoots(
return roots;
}
function resolveLocalMediaPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || HTTP_URL_RE.test(trimmed) || DATA_URL_RE.test(trimmed)) {
return undefined;
}
if (trimmed.startsWith("file://")) {
try {
return safeFileURLToPath(trimmed);
} catch {
return undefined;
}
}
if (trimmed.startsWith("~")) {
return resolveUserPath(trimmed);
}
if (path.isAbsolute(trimmed) || WINDOWS_DRIVE_RE.test(trimmed)) {
return path.resolve(trimmed);
}
return undefined;
}
/**
* @deprecated Kept for plugin-sdk compatibility. Media sources no longer widen allowed roots.
*/
export function appendLocalMediaParentRoots(
roots: readonly string[],
mediaSources?: readonly string[],
_mediaSources?: readonly string[],
): string[] {
const appended = Array.from(new Set(roots.map((root) => path.resolve(root))));
for (const source of mediaSources ?? []) {
const localPath = resolveLocalMediaPath(source);
if (!localPath) {
continue;
}
const parentDir = path.dirname(localPath);
if (parentDir === path.parse(parentDir).root) {
continue;
}
const normalizedParent = path.resolve(parentDir);
if (!appended.includes(normalizedParent)) {
appended.push(normalizedParent);
}
}
return appended;
return Array.from(new Set(roots.map((root) => path.resolve(root))));
}
export function getAgentScopedMediaLocalRootsForSources(params: {
export function getAgentScopedMediaLocalRootsForSources({
cfg,
agentId,
mediaSources: _mediaSources,
}: {
cfg: OpenClawConfig;
agentId?: string;
mediaSources?: readonly string[];
}): readonly string[] {
const roots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
if (resolveEffectiveToolFsWorkspaceOnly({ cfg: params.cfg, agentId: params.agentId })) {
return roots;
}
if (!resolveEffectiveToolFsRootExpansionAllowed({ cfg: params.cfg, agentId: params.agentId })) {
return roots;
}
return appendLocalMediaParentRoots(roots, params.mediaSources);
return getAgentScopedMediaLocalRoots(cfg, agentId);
}