Fix iMessage image attachment roots (#86569)

* fix imessage image attachment roots

* fix media tool inbound wildcard roots

* docs(changelog): add iMessage image attachment root fix entry for #86569

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-05-26 00:22:12 -07:00
committed by GitHub
parent 918472a27b
commit 2e17003165
10 changed files with 224 additions and 5 deletions
+1
View File
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under `~/Library/Messages/Attachments` (including the wildcard `/Users/*/Library/Messages/Attachments` root) are read through the existing inbound path policy instead of being rejected as `path-not-allowed`. Literal `localRoots` stays workspace-scoped. Fixes #30170. (#86569)
- QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `<qqmedia>` sends no longer silently fail when `HOME` and `OPENCLAW_HOME` differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.
- Update: report the primary malformed `openclaw.extensions` payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.
- Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.
+3
View File
@@ -222,6 +222,9 @@ export function createOpenClawTools(
workspaceDir,
sandbox,
fsPolicy: options?.fsPolicy,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
currentChannelId: options?.currentChannelId,
modelHasVision: options?.modelHasVision,
deferAutoModelResolution: true,
})
+85
View File
@@ -28,6 +28,9 @@ type MockOpenClawToolsOptions = {
sandboxRoot?: string;
sandboxFsBridge?: SandboxFsBridge;
fsPolicy?: NonNullable<Parameters<typeof createImageTool>[0]>["fsPolicy"];
agentChannel?: string | null;
agentAccountId?: string | null;
currentChannelId?: string | null;
modelHasVision?: boolean;
};
@@ -167,6 +170,9 @@ vi.mock("../openclaw-tools.js", async () => {
}
: undefined,
fsPolicy: options?.fsPolicy,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
currentChannelId: options?.currentChannelId,
modelHasVision: options?.modelHasVision,
});
return imageTool ? [imageTool] : [];
@@ -1640,6 +1646,85 @@ describe("image tool implicit imageModel config", () => {
});
});
it("allows image paths from the current iMessage account attachment roots", async () => {
const fetch = stubMinimaxOkFetch();
await withTempAgentDir(async (agentDir) => {
const attachmentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-root-"));
const imagePath = path.join(attachmentRoot, "photo.png");
await fs.writeFile(imagePath, Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
try {
const cfg: OpenClawConfig = {
...createMinimaxImageConfig(),
channels: {
imessage: {
accounts: {
work: {
attachmentRoots: [attachmentRoot],
},
},
},
},
};
const withoutChannel = createRequiredImageTool({ config: cfg, agentDir });
await expect(
withoutChannel.execute("t1", { prompt: "Describe.", image: imagePath }),
).rejects.toThrow(/not under an allowed directory/i);
const withImessage = createRequiredImageTool({
config: cfg,
agentDir,
agentChannel: "imessage",
agentAccountId: "work",
});
await expectImageToolExecOk(withImessage, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(attachmentRoot, { recursive: true, force: true });
}
});
});
it("allows image paths from current iMessage wildcard attachment roots", async () => {
const fetch = stubMinimaxOkFetch();
await withTempAgentDir(async (agentDir) => {
const attachmentRootParent = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-imessage-wildcard-root-"),
);
const attachmentRoot = path.join(attachmentRootParent, "work", "Attachments");
const imagePath = path.join(attachmentRoot, "photo.png");
await fs.mkdir(attachmentRoot, { recursive: true });
await fs.writeFile(imagePath, Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
try {
const cfg: OpenClawConfig = {
...createMinimaxImageConfig(),
channels: {
imessage: {
accounts: {
work: {
attachmentRoots: [path.join(attachmentRootParent, "*", "Attachments")],
},
},
},
},
};
const withImessage = createRequiredImageTool({
config: cfg,
agentDir,
agentChannel: "imessage",
agentAccountId: "work",
});
await expectImageToolExecOk(withImessage, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(attachmentRootParent, { recursive: true, force: true });
}
});
});
it("allows workspace images via createOpenClawCodingTools when workspace root is explicit", async () => {
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
const fetch = stubMinimaxOkFetch();
+14
View File
@@ -56,6 +56,7 @@ import {
import {
applyImageModelConfigDefaults,
buildTextToolResult,
resolveMediaToolInboundRoots,
resolveMediaToolLocalRoots,
resolveRemoteMediaSsrfPolicy,
resolvePromptAndModelOverride,
@@ -668,6 +669,9 @@ export function createImageTool(options?: {
workspaceDir?: string;
sandbox?: ImageSandboxConfig;
fsPolicy?: ToolFsPolicy;
agentChannel?: string | null;
agentAccountId?: string | null;
currentChannelId?: string | null;
/** If true, the model has native vision capability and images in the prompt are auto-injected */
modelHasVision?: boolean;
/**
@@ -900,9 +904,18 @@ export function createImageTool(options?: {
options?.workspaceDir,
{
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
cfg: options?.config,
channelId: options?.agentChannel ?? options?.currentChannelId,
accountId: options?.agentAccountId,
},
resolvedPath ? [resolvedPath] : undefined,
);
const mediaInboundRoots = resolveMediaToolInboundRoots({
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
cfg: options?.config,
channelId: options?.agentChannel ?? options?.currentChannelId,
accountId: options?.agentAccountId,
});
const media = isDataUrl
? await (async () => {
@@ -924,6 +937,7 @@ export function createImageTool(options?: {
: await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes,
localRoots: mediaLocalRoots,
inboundRoots: mediaInboundRoots,
ssrfPolicy: remoteMediaSsrfPolicy,
imageCompression,
});
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasGenerationToolAvailability,
isCapabilityProviderConfigured,
resolveMediaToolInboundRoots,
resolveCapabilityModelConfigForTool,
resolveMediaToolLocalRoots,
resolveModelFromRegistry,
@@ -56,6 +57,43 @@ describe("resolveMediaToolLocalRoots", () => {
expect(normalizedRoots).not.toContain(normalizeHostPath(moviesDir));
expect(normalizedRoots).not.toContain(normalizeHostPath("/"));
});
it("keeps channel inbound attachment roots separate from local roots", () => {
const accountRoot = path.join("/tmp", "openclaw-imessage-work");
const sharedRoot = path.join("/tmp", "openclaw-imessage-shared");
const cfg = {
channels: {
imessage: {
attachmentRoots: [sharedRoot],
accounts: {
work: {
attachmentRoots: [accountRoot],
},
},
},
},
};
const withoutChannel = resolveMediaToolLocalRoots(undefined, { cfg });
expect(withoutChannel.map(normalizeHostPath)).not.toContain(normalizeHostPath(accountRoot));
expect(withoutChannel.map(normalizeHostPath)).not.toContain(normalizeHostPath(sharedRoot));
expect(resolveMediaToolInboundRoots({ cfg })).toEqual([]);
const withImessage = resolveMediaToolLocalRoots(undefined, {
cfg,
channelId: "imessage",
accountId: "work",
});
expect(withImessage.map(normalizeHostPath)).not.toContain(normalizeHostPath(accountRoot));
expect(withImessage.map(normalizeHostPath)).not.toContain(normalizeHostPath(sharedRoot));
expect(
resolveMediaToolInboundRoots({
cfg,
channelId: "imessage",
accountId: "work",
}),
).toEqual([accountRoot, sharedRoot, "/Users/*/Library/Messages/Attachments"]);
});
});
describe("resolveModelFromRegistry", () => {
+27 -2
View File
@@ -2,6 +2,8 @@ import { type Api, type Model } from "@earendil-works/pi-ai";
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
import { resolveChannelInboundAttachmentRootsForChannel } from "../../media/channel-inbound-roots.js";
import { normalizeInboundPathRoots } from "../../media/inbound-path-policy.js";
import { getDefaultLocalRoots } from "../../media/web-media.js";
import { readSnakeCaseParamRaw } from "../../param-key.js";
import { loadCapabilityManifestSnapshot } from "../../plugins/capability-provider-runtime.js";
@@ -542,7 +544,12 @@ export function buildTaskRunDetails(
export function resolveMediaToolLocalRoots(
workspaceDirRaw: string | undefined,
options?: { workspaceOnly?: boolean },
options?: {
workspaceOnly?: boolean;
cfg?: OpenClawConfig;
channelId?: string | null;
accountId?: string | null;
},
_mediaSources?: readonly string[],
): string[] {
const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw);
@@ -550,7 +557,25 @@ export function resolveMediaToolLocalRoots(
return workspaceDir ? [workspaceDir] : [];
}
const roots = getDefaultLocalRoots();
return workspaceDir ? uniqueStrings([...roots, workspaceDir]) : [...roots];
return uniqueStrings([...roots, ...(workspaceDir ? [workspaceDir] : [])]);
}
export function resolveMediaToolInboundRoots(options?: {
workspaceOnly?: boolean;
cfg?: OpenClawConfig;
channelId?: string | null;
accountId?: string | null;
}): string[] {
if (options?.workspaceOnly || !options?.cfg || !options.channelId) {
return [];
}
return normalizeInboundPathRoots(
resolveChannelInboundAttachmentRootsForChannel({
cfg: options.cfg,
channelId: options.channelId,
accountId: options.accountId,
}),
);
}
export function resolvePromptAndModelOverride(
@@ -10,6 +10,7 @@ vi.mock("../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks);
import {
resolveChannelInboundAttachmentRoots,
resolveChannelInboundAttachmentRootsForChannel,
resolveChannelRemoteInboundAttachmentRoots,
} from "./channel-inbound-roots.js";
@@ -142,4 +143,33 @@ describe("channel inbound roots fast path", () => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync,
).toHaveBeenCalledOnce();
});
it("resolves local inbound roots from explicit channel context", () => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation(
({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => {
if (dirName === "toolchat" && artifactBasename === "media-contract-api.js") {
return {
resolveInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [
`/tool/${accountId}`,
],
};
}
throw unableToResolve(dirName, artifactBasename);
},
);
expect(
resolveChannelInboundAttachmentRootsForChannel({
cfg,
channelId: "toolchat",
accountId: "personal",
}),
).toEqual(["/tool/personal"]);
expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith(
{
dirName: "toolchat",
artifactBasename: "media-contract-api.js",
},
);
});
});
+14 -2
View File
@@ -65,15 +65,27 @@ function findChannelMediaContractApi(
export function resolveChannelInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): readonly string[] | undefined {
return resolveChannelInboundAttachmentRootsForChannel({
cfg: params.cfg,
channelId: params.ctx.Surface ?? params.ctx.Provider,
accountId: params.ctx.AccountId,
});
}
export function resolveChannelInboundAttachmentRootsForChannel(params: {
cfg: OpenClawConfig;
channelId?: string | null;
accountId?: string | null;
}): readonly string[] | undefined {
const contractApi = findChannelMediaContractApi(
params.ctx.Surface ?? params.ctx.Provider,
params.channelId,
"resolveInboundAttachmentRoots",
);
if (contractApi?.resolveInboundAttachmentRoots) {
return contractApi.resolveInboundAttachmentRoots({
cfg: params.cfg,
accountId: params.ctx.AccountId,
accountId: params.accountId ?? undefined,
});
}
return undefined;
+8
View File
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { assertNoWindowsNetworkPath } from "../infra/local-file-access.js";
import { isPathInside } from "../infra/path-guards.js";
import { isInboundPathAllowed } from "./inbound-path-policy.js";
import { getDefaultMediaLocalRoots } from "./local-roots.js";
import { resolveInboundMediaReference } from "./media-reference.js";
@@ -32,6 +33,7 @@ export function getDefaultLocalRoots(): readonly string[] {
export async function assertLocalMediaAllowed(
mediaPath: string,
localRoots: readonly string[] | "any" | undefined,
options?: { inboundRoots?: readonly string[] },
): Promise<void> {
if (localRoots === "any") {
return;
@@ -47,6 +49,12 @@ export async function assertLocalMediaAllowed(
cause: err,
});
}
if (
options?.inboundRoots?.length &&
isInboundPathAllowed({ filePath: mediaPath, roots: options.inboundRoots })
) {
return;
}
const roots = localRoots ?? getDefaultLocalRoots();
let resolved: string;
try {
+4 -1
View File
@@ -57,6 +57,8 @@ type WebMediaOptions = {
workspaceDir?: string;
/** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */
localRoots?: readonly string[] | "any";
/** Channel inbound attachment root patterns checked with inbound path policy semantics. */
inboundRoots?: readonly string[];
/** Caller already validated the local path (sandbox/other guards); requires readFile override. */
sandboxValidated?: boolean;
readFile?: (filePath: string) => Promise<Buffer>;
@@ -773,6 +775,7 @@ async function loadWebMediaInternal(
trustExplicitProxyDns,
workspaceDir,
localRoots,
inboundRoots,
sandboxValidated = false,
readFile: readFileOverride,
hostReadCapability = false,
@@ -968,7 +971,7 @@ async function loadWebMediaInternal(
// Guard local reads against allowed directory roots to prevent file exfiltration.
if (!(sandboxValidated || localRoots === "any")) {
await assertLocalMediaAllowed(mediaUrl, localRoots);
await assertLocalMediaAllowed(mediaUrl, localRoots, { inboundRoots });
}
// Local path