Compare commits

...

10 Commits

Author SHA1 Message Date
Vincent Koc
56b5340a72 Agents: cover iMessage image roots 2026-03-07 09:19:56 -08:00
Vincent Koc
3bdf9c7677 Agents: use agent-scoped image roots 2026-03-07 09:19:48 -08:00
Vincent Koc
84c619d957 Web: cover wildcard local media roots 2026-03-07 09:19:41 -08:00
Vincent Koc
50976575e1 Web: allow wildcard local media roots 2026-03-07 09:19:33 -08:00
Vincent Koc
078e622161 Media: add attachment root regression tests 2026-03-07 09:19:24 -08:00
Vincent Koc
df2db2175e Media: merge iMessage attachment roots 2026-03-07 09:19:16 -08:00
Vincent Koc
28b17181f6 Agents: pass agent-scoped image roots 2026-03-07 09:19:07 -08:00
Vincent Koc
a66b179b12 Agents: cover message tool agent override 2026-03-07 09:18:56 -08:00
Vincent Koc
60f1842472 Agents: pass explicit message tool agent id 2026-03-07 09:18:44 -08:00
Vincent Koc
1f7892852e Changelog: note media root fixes 2026-03-07 09:18:31 -08:00
10 changed files with 162 additions and 6 deletions

View File

@ -68,6 +68,8 @@ Docs: https://docs.openclaw.ai
- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao.
- Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.
- Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.
- Message tool agent-scoped media roots: resolve the effective agent for direct tool runs even without an `agentSessionKey`, so cron and hook sessions keep the agent workspace in `mediaLocalRoots` and can send workspace files again. (#36185) Thanks @ravz and @vincentkoc.
- iMessage/image attachment root parity: merge iMessage `attachmentRoots` into the image tool allowlist and honor wildcard local roots during local media validation, restoring image-tool access to iMessage attachments after the 2026.2.26 hardening changes. (#30170) Thanks @macminikenny1 and @vincentkoc.
- Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
- Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
- iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.

View File

@ -80,6 +80,12 @@ export function createOpenClawTools(options?: {
? createImageTool({
config: options?.config,
agentDir: options.agentDir,
agentId:
options?.requesterAgentIdOverride ??
resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
}),
workspaceDir,
sandbox:
options?.sandboxRoot && options?.sandboxFsBridge
@ -113,6 +119,7 @@ export function createOpenClawTools(options?: {
? null
: createMessageTool({
agentAccountId: options?.agentAccountId,
agentId: options?.requesterAgentIdOverride,
agentSessionKey: options?.agentSessionKey,
config: options?.config,
currentChannelId: options?.currentChannelId,

View File

@ -48,6 +48,21 @@ async function withTempWorkspacePng(
}
}
async function withTempAttachmentPng(
cb: (args: { attachmentRoot: string; imagePath: string }) => Promise<void>,
) {
const attachmentParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-"));
try {
const attachmentRoot = path.join(attachmentParent, "Library", "Messages", "Attachments");
await fs.mkdir(attachmentRoot, { recursive: true });
const imagePath = path.join(attachmentRoot, "photo.png");
await fs.writeFile(imagePath, Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
await cb({ attachmentRoot, imagePath });
} finally {
await fs.rm(attachmentParent, { recursive: true, force: true });
}
}
function stubMinimaxOkFetch() {
const fetch = vi.fn().mockResolvedValue({
ok: true,
@ -495,6 +510,27 @@ describe("image tool implicit imageModel config", () => {
});
});
it("allows iMessage attachment paths from configured attachment roots", async () => {
await withTempAttachmentPng(async ({ attachmentRoot, imagePath }) => {
const fetch = stubMinimaxOkFetch();
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
...createMinimaxImageConfig(),
channels: {
imessage: {
attachmentRoots: [attachmentRoot],
},
},
};
const tool = createRequiredImageTool({ config: cfg, agentDir });
await expectImageToolExecOk(tool, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
});
});
});
it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => {
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
const fetch = stubMinimaxOkFetch();

View File

@ -1,6 +1,7 @@
import { type Context, complete } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
@ -270,6 +271,7 @@ async function runImagePrompt(params: {
export function createImageTool(options?: {
config?: OpenClawConfig;
agentDir?: string;
agentId?: string;
workspaceDir?: string;
sandbox?: ImageSandboxConfig;
fsPolicy?: ToolFsPolicy;
@ -298,9 +300,15 @@ export function createImageTool(options?: {
? "Analyze one or more images with a vision model. Use image for a single path/URL, or images for multiple (up to 20). Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you."
: "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze.";
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, {
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
});
const localRoots =
options?.fsPolicy?.workspaceOnly === true
? resolveMediaToolLocalRoots(options?.workspaceDir, { workspaceOnly: true })
: Array.from(
new Set([
...getAgentScopedMediaLocalRoots(options?.config ?? {}, options?.agentId),
...resolveMediaToolLocalRoots(options?.workspaceDir),
]),
);
return {
label: "Image",

View File

@ -116,6 +116,25 @@ describe("message tool agent routing", () => {
expect(call?.agentId).toBe("alpha");
expect(call?.sessionKey).toBe("agent:alpha:main");
});
it("uses the explicit agentId override when no session key is present", async () => {
mockSendResult();
const tool = createMessageTool({
agentId: "cron-agent",
config: {} as never,
});
await tool.execute("1", {
action: "send",
target: "telegram:123",
message: "hi",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.agentId).toBe("cron-agent");
expect(call?.sessionKey).toBeUndefined();
});
});
describe("message tool path passthrough", () => {

View File

@ -488,6 +488,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
type MessageToolOptions = {
agentAccountId?: string;
agentSessionKey?: string;
agentId?: string;
config?: OpenClawConfig;
currentChannelId?: string;
currentChannelProvider?: string;
@ -775,9 +776,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
gateway,
toolContext,
sessionKey: options?.agentSessionKey,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
agentId:
options?.agentId?.trim() ||
resolveSessionAgentId({ sessionKey: options?.agentSessionKey, config: cfg }),
sandboxRoot: options?.sandboxRoot,
abortSignal: signal,
});

View File

@ -0,0 +1,44 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveUserPath } from "../utils.js";
import { getAgentScopedMediaLocalRoots } from "./local-roots.js";
describe("getAgentScopedMediaLocalRoots", () => {
it("merges iMessage attachment roots into the agent-scoped allowlist", () => {
const cfg: OpenClawConfig = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
},
},
};
expect(getAgentScopedMediaLocalRoots(cfg)).toContain("/Users/*/Library/Messages/Attachments");
});
it("adds the resolved agent workspace without dropping attachment roots", () => {
const cfg: OpenClawConfig = {
channels: {
imessage: {
attachmentRoots: ["/tmp/imessage-attachments"],
},
},
agents: {
list: [
{
id: "clawdy",
workspace: "~/agent-workspace",
},
],
},
};
const roots = getAgentScopedMediaLocalRoots(cfg, "clawdy");
expect(roots).toContain("/tmp/imessage-attachments");
expect(roots).toContain(path.resolve(resolveStateDir(), "workspace"));
expect(roots).toContain(path.resolve(resolveUserPath("~/agent-workspace")));
});
});

View File

@ -3,6 +3,7 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { resolveIMessageAttachmentRoots } from "./inbound-path-policy.js";
type BuildMediaLocalRootsOptions = {
preferredTmpDir?: string;
@ -41,6 +42,11 @@ export function getAgentScopedMediaLocalRoots(
agentId?: string,
): readonly string[] {
const roots = buildMediaLocalRoots(resolveStateDir());
for (const attachmentRoot of resolveIMessageAttachmentRoots({ cfg })) {
if (!roots.includes(attachmentRoot)) {
roots.push(attachmentRoot);
}
}
if (!agentId?.trim()) {
return roots;
}

View File

@ -502,4 +502,30 @@ describe("local media root guard", () => {
}),
);
});
it("allows wildcard-style local roots for iMessage attachment paths", async () => {
const attachmentRoot = path.join(os.tmpdir(), "openclaw-imessage-root");
const attachmentFile = path.join(
attachmentRoot,
"alice",
"Library",
"Messages",
"Attachments",
"x.bin",
);
const readFile = vi.fn(async () => Buffer.from("generated-media"));
const wildcardRoot = path.join(attachmentRoot, "*", "Library", "Messages", "Attachments");
await expect(
loadWebMedia(attachmentFile, {
maxBytes: 1024 * 1024,
localRoots: [wildcardRoot],
readFile,
}),
).resolves.toEqual(
expect.objectContaining({
kind: "unknown",
}),
);
});
});

View File

@ -12,6 +12,7 @@ import {
optimizeImageToPng,
resizeToJpeg,
} from "../media/image-ops.js";
import { isInboundPathAllowed } from "../media/inbound-path-policy.js";
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { detectMime, extensionForMime, kindFromMime } from "../media/mime.js";
import { resolveUserPath } from "../utils.js";
@ -115,6 +116,12 @@ async function assertLocalMediaAllowed(
}
}
for (const root of roots) {
if (root.includes("*")) {
if (isInboundPathAllowed({ filePath: resolved, roots: [root] })) {
return;
}
continue;
}
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(root);