mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user