Auto-reply: fast-path sandbox media root resolution

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 13:58:08 -04:00
parent e606656b56
commit f334ca2b50
8 changed files with 343 additions and 86 deletions

View File

@@ -1,12 +1,10 @@
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";
} from "./media-contract-api.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,

View File

@@ -0,0 +1,7 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -13,8 +13,12 @@ const sandboxMocks = vi.hoisted(() => ({
const childProcessMocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const mediaRootMocks = vi.hoisted(() => ({
resolveChannelRemoteInboundAttachmentRoots: vi.fn(),
}));
vi.mock("../agents/sandbox.js", () => sandboxMocks);
vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
@@ -28,6 +32,7 @@ import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
afterEach(() => {
vi.restoreAllMocks();
childProcessMocks.spawn.mockClear();
mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReset();
});
function createRemoteStageParams(home: string): {
@@ -38,6 +43,9 @@ function createRemoteStageParams(home: string): {
} {
const sessionKey = "agent:main:main";
vi.mocked(sandboxMocks.ensureSandboxWorkspaceForSession).mockResolvedValue(null);
mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReturnValue([
"/Users/demo/Library/Messages/Attachments",
]);
return {
cfg: createSandboxMediaStageConfig(home),
workspaceDir: join(home, "openclaw"),

View File

@@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import path, { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MEDIA_MAX_BYTES } from "../media/store.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
import {
createSandboxMediaContexts,
createSandboxMediaStageConfig,
@@ -10,93 +11,111 @@ import {
const sandboxMocks = vi.hoisted(() => ({
ensureSandboxWorkspaceForSession: vi.fn(),
assertSandboxPath: vi.fn(),
}));
const childProcessMocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const sandboxModuleId = new URL("../agents/sandbox.js", import.meta.url).pathname;
const fsSafeModuleId = new URL("../infra/fs-safe.js", import.meta.url).pathname;
const fsSafeMocks = vi.hoisted(() => {
class MockSafeOpenError extends Error {
readonly code: string;
let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSandboxMedia;
constructor(code: string, message: string) {
super(message);
this.name = "SafeOpenError";
this.code = code;
}
}
async function loadFreshStageSandboxMediaModuleForTest() {
vi.resetModules();
vi.doMock(sandboxModuleId, () => sandboxMocks);
vi.doMock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: childProcessMocks.spawn,
};
});
vi.doMock(fsSafeModuleId, async () => {
const actual = await vi.importActual<typeof import("../infra/fs-safe.js")>(fsSafeModuleId);
return {
...actual,
copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => {
const sourceStat = await fs.stat(sourcePath);
if (typeof maxBytes === "number" && sourceStat.size > maxBytes) {
throw new actual.SafeOpenError(
"too-large",
`file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`,
);
}
await fs.mkdir(rootDir, { recursive: true });
const rootReal = await fs.realpath(rootDir);
const destPath = path.resolve(rootReal, relativePath);
const rootPrefix = `${rootReal}${path.sep}`;
if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) {
throw new actual.SafeOpenError("outside-workspace", "file is outside workspace root");
}
const parentDir = dirname(destPath);
const relativeParent = path.relative(rootReal, parentDir);
if (relativeParent && !relativeParent.startsWith("..")) {
let cursor = rootReal;
for (const segment of relativeParent.split(path.sep)) {
cursor = path.join(cursor, segment);
try {
const stat = await fs.lstat(cursor);
if (stat.isSymbolicLink()) {
throw new actual.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
await fs.mkdir(cursor, { recursive: true });
continue;
}
throw error;
}
}
}
try {
const destStat = await fs.lstat(destPath);
if (destStat.isSymbolicLink()) {
throw new actual.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await fs.copyFile(sourcePath, destPath);
}),
};
});
const replyModule = await import("./reply/stage-sandbox-media.js");
return {
stageSandboxMedia: replyModule.stageSandboxMedia,
SafeOpenError: MockSafeOpenError,
copyFileWithinRoot: vi.fn(),
readLocalFileSafely: vi.fn(),
};
});
const mediaRootMocks = vi.hoisted(() => ({
resolveChannelRemoteInboundAttachmentRoots: vi.fn(),
}));
vi.mock("../agents/sandbox.js", () => sandboxMocks);
vi.mock("../agents/sandbox-paths.js", () => ({
assertSandboxPath: sandboxMocks.assertSandboxPath,
}));
vi.mock("node:child_process", () => childProcessMocks);
vi.mock("../infra/fs-safe.js", () => fsSafeMocks);
vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks);
async function copyFileWithinRootForTest({
sourcePath,
rootDir,
relativePath,
maxBytes,
}: {
sourcePath: string;
rootDir: string;
relativePath: string;
maxBytes?: number;
}) {
const sourceStat = await fs.stat(sourcePath);
if (typeof maxBytes === "number" && sourceStat.size > maxBytes) {
throw new fsSafeMocks.SafeOpenError(
"too-large",
`file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`,
);
}
await fs.mkdir(rootDir, { recursive: true });
const rootReal = await fs.realpath(rootDir);
const destPath = path.resolve(rootReal, relativePath);
const rootPrefix = `${rootReal}${path.sep}`;
if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) {
throw new fsSafeMocks.SafeOpenError("outside-workspace", "file is outside workspace root");
}
const parentDir = dirname(destPath);
const relativeParent = path.relative(rootReal, parentDir);
if (relativeParent && !relativeParent.startsWith("..")) {
let cursor = rootReal;
for (const segment of relativeParent.split(path.sep)) {
cursor = path.join(cursor, segment);
try {
const stat = await fs.lstat(cursor);
if (stat.isSymbolicLink()) {
throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
await fs.mkdir(cursor, { recursive: true });
continue;
}
throw error;
}
}
}
try {
const destStat = await fs.lstat(destPath);
if (destStat.isSymbolicLink()) {
throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await fs.copyFile(sourcePath, destPath);
}
async function loadStageSandboxMediaInTempHome() {
beforeEach(() => {
sandboxMocks.ensureSandboxWorkspaceForSession.mockReset();
sandboxMocks.assertSandboxPath.mockReset().mockResolvedValue({ resolved: "", relative: "" });
childProcessMocks.spawn.mockClear();
({ stageSandboxMedia } = await loadFreshStageSandboxMediaModuleForTest());
}
fsSafeMocks.copyFileWithinRoot.mockReset().mockImplementation(copyFileWithinRootForTest);
mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots
.mockReset()
.mockReturnValue(["/Users/demo/Library/Messages/Attachments"]);
});
afterEach(() => {
vi.restoreAllMocks();
@@ -134,7 +153,6 @@ async function writeInboundMedia(
describe("stageSandboxMedia", () => {
it("stages allowed media and blocks unsafe paths", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
{
@@ -179,6 +197,7 @@ describe("stageSandboxMedia", () => {
}
{
expect(mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots).not.toHaveBeenCalled();
childProcessMocks.spawn.mockClear();
const { ctx, sessionCtx } = createSandboxMediaContexts("/etc/passwd");
ctx.Provider = "imessage";
@@ -202,7 +221,6 @@ describe("stageSandboxMedia", () => {
it("blocks destination symlink escapes when staging into sandbox workspace", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
const mediaPath = await writeInboundMedia(home, "payload.txt", "PAYLOAD");
@@ -234,7 +252,6 @@ describe("stageSandboxMedia", () => {
it("skips oversized media staging and keeps original media paths", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
const mediaPath = await writeInboundMedia(

View File

@@ -48,7 +48,9 @@ export async function stageSandboxMedia(params: {
}
await fs.mkdir(effectiveWorkspaceDir, { recursive: true });
const remoteAttachmentRoots = resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? [];
const remoteAttachmentRoots = ctx.MediaRemoteHost
? (resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? [])
: [];
const usedNames = new Set<string>();
const staged = new Map<string, string>(); // absolute source -> relative sandbox path

View File

@@ -7,7 +7,7 @@ export async function withSandboxMediaTempHome<T>(
prefix: string,
fn: (home: string) => Promise<T>,
): Promise<T> {
return withTempHomeBase(async (home) => await fn(home), { prefix });
return withTempHomeBase(async (home) => await fn(home), { prefix, skipSessionCleanup: true });
}
export function createSandboxMediaContexts(mediaPath: string): {

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/types.js";
const publicSurfaceLoaderMocks = vi.hoisted(() => ({
loadBundledPluginPublicArtifactModuleSync: vi.fn(),
}));
const bootstrapRegistryMocks = vi.hoisted(() => ({
getBootstrapChannelPlugin: vi.fn(),
}));
vi.mock("../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks);
vi.mock("../channels/plugins/bootstrap-registry.js", () => bootstrapRegistryMocks);
import {
resolveChannelInboundAttachmentRoots,
resolveChannelRemoteInboundAttachmentRoots,
} from "./channel-inbound-roots.js";
const cfg = {
channels: {},
} as OpenClawConfig;
function unableToResolve(dirName: string, artifactBasename: string): Error {
return new Error(
`Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`,
);
}
function createContext(provider: string, accountId = "work"): MsgContext {
return {
Body: "hi",
From: "imessage:work:demo",
To: "+2000",
ChatType: "direct",
Provider: provider,
AccountId: accountId,
};
}
beforeEach(() => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockReset();
bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReset();
});
describe("channel inbound roots fast path", () => {
it("prefers media contract artifacts over full channel bootstrap", () => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation(
({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => {
if (dirName === "imessage" && artifactBasename === "media-contract-api.js") {
return {
resolveInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [
`/local/${accountId}`,
],
resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [
`/remote/${accountId}`,
],
};
}
throw unableToResolve(dirName, artifactBasename);
},
);
expect(
resolveChannelInboundAttachmentRoots({
cfg,
ctx: createContext("imessage"),
}),
).toEqual(["/local/work"]);
expect(
resolveChannelRemoteInboundAttachmentRoots({
cfg,
ctx: createContext("imessage"),
}),
).toEqual(["/remote/work"]);
expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled();
expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith(
{
dirName: "imessage",
artifactBasename: "media-contract-api.js",
},
);
});
it("falls back to generic contract artifacts before full channel bootstrap", () => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation(
({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => {
if (dirName === "legacy-channel" && artifactBasename === "contract-api.js") {
return {
resolveRemoteInboundAttachmentRoots: () => ["/legacy-remote"],
};
}
throw unableToResolve(dirName, artifactBasename);
},
);
expect(
resolveChannelRemoteInboundAttachmentRoots({
cfg,
ctx: createContext("legacy-channel"),
}),
).toEqual(["/legacy-remote"]);
expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled();
expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith(
{
dirName: "legacy-channel",
artifactBasename: "media-contract-api.js",
},
);
expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith(
{
dirName: "legacy-channel",
artifactBasename: "contract-api.js",
},
);
});
it("uses channel bootstrap when no public root contract exists", () => {
publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation(
({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => {
throw unableToResolve(dirName, artifactBasename);
},
);
bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReturnValue({
messaging: {
resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [
`/bootstrap/${accountId}`,
],
},
});
expect(
resolveChannelRemoteInboundAttachmentRoots({
cfg,
ctx: createContext("bootstrap-channel"),
}),
).toEqual(["/bootstrap/work"]);
expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).toHaveBeenCalledWith(
"bootstrap-channel",
);
});
});

View File

@@ -1,8 +1,71 @@
import type { MsgContext } from "../auto-reply/templating.js";
import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js";
import type { OpenClawConfig } from "../config/types.js";
import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
type ChannelMediaContractApi = {
resolveInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => readonly string[] | undefined;
resolveRemoteInboundAttachmentRoots?: (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => readonly string[] | undefined;
};
type ChannelMediaRootResolver = keyof ChannelMediaContractApi;
const mediaContractApiByResolver = new Map<string, ChannelMediaContractApi | null>();
function mediaContractCacheKey(channelId: string, resolver: ChannelMediaRootResolver): string {
return `${channelId}:${resolver}`;
}
function loadChannelMediaContractApi(
channelId: string,
resolver: ChannelMediaRootResolver,
): ChannelMediaContractApi | undefined {
const cacheKey = mediaContractCacheKey(channelId, resolver);
if (mediaContractApiByResolver.has(cacheKey)) {
return mediaContractApiByResolver.get(cacheKey) ?? undefined;
}
for (const artifactBasename of ["media-contract-api.js", "contract-api.js"]) {
try {
const loaded = loadBundledPluginPublicArtifactModuleSync<ChannelMediaContractApi>({
dirName: channelId,
artifactBasename,
});
if (typeof loaded[resolver] === "function") {
mediaContractApiByResolver.set(cacheKey, loaded);
return loaded;
}
} catch (error) {
if (
error instanceof Error &&
error.message.startsWith("Unable to resolve bundled plugin public surface ")
) {
continue;
}
}
}
mediaContractApiByResolver.set(cacheKey, null);
return undefined;
}
function findChannelMediaContractApi(
channelId: string | null | undefined,
resolver: ChannelMediaRootResolver,
) {
const normalized = normalizeOptionalLowercaseString(channelId);
if (!normalized) {
return undefined;
}
return loadChannelMediaContractApi(normalized, resolver);
}
function findChannelMessagingAdapter(channelId?: string | null) {
const normalized = normalizeOptionalLowercaseString(channelId);
if (!normalized) {
@@ -15,6 +78,16 @@ export function resolveChannelInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): readonly string[] | undefined {
const contractApi = findChannelMediaContractApi(
params.ctx.Surface ?? params.ctx.Provider,
"resolveInboundAttachmentRoots",
);
if (contractApi?.resolveInboundAttachmentRoots) {
return contractApi.resolveInboundAttachmentRoots({
cfg: params.cfg,
accountId: params.ctx.AccountId,
});
}
const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider);
return messaging?.resolveInboundAttachmentRoots?.({
cfg: params.cfg,
@@ -26,6 +99,16 @@ export function resolveChannelRemoteInboundAttachmentRoots(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): readonly string[] | undefined {
const contractApi = findChannelMediaContractApi(
params.ctx.Surface ?? params.ctx.Provider,
"resolveRemoteInboundAttachmentRoots",
);
if (contractApi?.resolveRemoteInboundAttachmentRoots) {
return contractApi.resolveRemoteInboundAttachmentRoots({
cfg: params.cfg,
accountId: params.ctx.AccountId,
});
}
const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider);
return messaging?.resolveRemoteInboundAttachmentRoots?.({
cfg: params.cfg,