diff --git a/CHANGELOG.md b/CHANGELOG.md index 8365e39fc30e..7ea48f071cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index c6e070fd546d..133113f4c5dc 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, }) diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 886215906462..709f1c8d5cf1 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -28,6 +28,9 @@ type MockOpenClawToolsOptions = { sandboxRoot?: string; sandboxFsBridge?: SandboxFsBridge; fsPolicy?: NonNullable[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(); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 27eac69cf2a5..f9cc120e8094 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -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, }); diff --git a/src/agents/tools/media-tool-shared.test.ts b/src/agents/tools/media-tool-shared.test.ts index daf8c6fb0ff9..5af65ecc31bd 100644 --- a/src/agents/tools/media-tool-shared.test.ts +++ b/src/agents/tools/media-tool-shared.test.ts @@ -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", () => { diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 6eb448a1a71d..9476a687e6cf 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -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( diff --git a/src/media/channel-inbound-roots.fast-path.test.ts b/src/media/channel-inbound-roots.fast-path.test.ts index c053d8aefab1..9214963810bc 100644 --- a/src/media/channel-inbound-roots.fast-path.test.ts +++ b/src/media/channel-inbound-roots.fast-path.test.ts @@ -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", + }, + ); + }); }); diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts index 8b80c6bee400..a91bb91d4b71 100644 --- a/src/media/channel-inbound-roots.ts +++ b/src/media/channel-inbound-roots.ts @@ -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; diff --git a/src/media/local-media-access.ts b/src/media/local-media-access.ts index d12fc404d251..aad2d2cf1e1f 100644 --- a/src/media/local-media-access.ts +++ b/src/media/local-media-access.ts @@ -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 { 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 { diff --git a/src/media/web-media.ts b/src/media/web-media.ts index fe40c0b59c93..6f8a3a7f5302 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -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; @@ -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