fix(agents): forward ACP spawn attachments

Forward initial image/file attachments when spawning ACP subagents through the existing sessions_spawn attachment opt-in. Remove the PR-only acpEnabled config split so ACP uses the same attachment gate as other runtimes.

Also fix the PR branch CI fallout: type the browser element CLI request mock and use Vitest env stubs in the Azure speech test to satisfy the changed-path security scan.

Verification:
- GitHub CI passed on f6ca26b160.
- Autoreview clean.
- Crabbox AWS live OpenAI proof passed: cbx_a576d49493fe / run_081dcc6c6a1b.

Thanks @zhangguiping-xydt.
This commit is contained in:
zhang-guiping
2026-05-30 04:08:19 +08:00
committed by GitHub
parent f8ad20b87e
commit 689e8ec893
13 changed files with 428 additions and 53 deletions

View File

@@ -386,12 +386,13 @@ Controls inline attachment support for `sessions_spawn`.
<AccordionGroup>
<Accordion title="Attachment notes">
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
- Attachments require `enabled: true`.
- Subagent attachments are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
- ACP attachments are image-only and forwarded inline to the ACP runtime after the same file count, per-file byte, and total byte limits pass.
- Attachment content is automatically redacted from transcript persistence.
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
- File permissions are `0700` for directories and `0600` for files.
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
- Subagent attachment file permissions are `0700` for directories and `0600` for files.
- Subagent cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
</Accordion>
</AccordionGroup>

View File

@@ -17,29 +17,23 @@ vi.mock("./tts.js", async (importOriginal) => {
import { buildAzureSpeechProvider } from "./speech-provider.js";
describe("buildAzureSpeechProvider", () => {
const originalEnv = {
AZURE_SPEECH_KEY: process.env.AZURE_SPEECH_KEY,
AZURE_SPEECH_API_KEY: process.env.AZURE_SPEECH_API_KEY,
AZURE_SPEECH_REGION: process.env.AZURE_SPEECH_REGION,
AZURE_SPEECH_ENDPOINT: process.env.AZURE_SPEECH_ENDPOINT,
SPEECH_KEY: process.env.SPEECH_KEY,
SPEECH_REGION: process.env.SPEECH_REGION,
};
const envKeys = [
"AZURE_SPEECH_KEY",
"AZURE_SPEECH_API_KEY",
"AZURE_SPEECH_REGION",
"AZURE_SPEECH_ENDPOINT",
"SPEECH_KEY",
"SPEECH_REGION",
] as const;
beforeEach(() => {
for (const key of Object.keys(originalEnv)) {
delete process.env[key];
for (const key of envKeys) {
vi.stubEnv(key, undefined);
}
});
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
vi.unstubAllEnvs();
azureSpeechTTSMock.mockClear();
listAzureSpeechVoicesMock.mockClear();
vi.restoreAllMocks();
@@ -52,12 +46,6 @@ describe("buildAzureSpeechProvider", () => {
it("reports configured only when key plus region or endpoint is available", () => {
const provider = buildAzureSpeechProvider();
delete process.env.AZURE_SPEECH_KEY;
delete process.env.AZURE_SPEECH_API_KEY;
delete process.env.SPEECH_KEY;
delete process.env.AZURE_SPEECH_REGION;
delete process.env.SPEECH_REGION;
delete process.env.AZURE_SPEECH_ENDPOINT;
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30_000 })).toBe(false);
expect(provider.isConfigured({ providerConfig: { apiKey: "key" }, timeoutMs: 30_000 })).toBe(
@@ -70,8 +58,8 @@ describe("buildAzureSpeechProvider", () => {
}),
).toBe(true);
process.env.AZURE_SPEECH_KEY = "env-key";
process.env.AZURE_SPEECH_REGION = "eastus";
vi.stubEnv("AZURE_SPEECH_KEY", "env-key");
vi.stubEnv("AZURE_SPEECH_REGION", "eastus");
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30_000 })).toBe(true);
});

View File

@@ -995,6 +995,45 @@ describe("spawnAcpDirect", () => {
expectGatewayMethodNotCalled("agent");
});
it("forwards prepared image attachments through the gateway agent call", async () => {
const imageBase64 = Buffer.from("png-bytes").toString("base64");
const result = await spawnAcpDirect(
{
task: "describe the image",
agentId: "codex",
attachments: [{ mediaType: "image/png", data: imageBase64 }],
},
{
agentSessionKey: "agent:main:main",
},
);
expectAcceptedSpawn(result);
const agentCall = findAgentGatewayCall();
expect(agentCall?.params?.attachments).toEqual([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: imageBase64 },
},
]);
});
it("omits attachments from gateway call when none are provided", async () => {
const result = await spawnAcpDirect(
{
task: "hello",
agentId: "codex",
},
{
agentSessionKey: "agent:main:main",
},
);
expectAcceptedSpawn(result);
const agentCall = findAgentGatewayCall();
expect(agentCall?.params).not.toHaveProperty("attachments");
});
it("maps OpenClaw ACP runtime agent aliases to their configured harness id", async () => {
replaceSpawnConfig({
...createDefaultSpawnConfig(),

View File

@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import type { AcpTurnAttachment } from "../acp/control-plane/manager.types.js";
import {
cleanupFailedAcpSpawn,
type AcpSpawnRuntimeCloseHandle,
@@ -117,8 +118,34 @@ export type SpawnAcpParams = {
thread?: boolean;
sandbox?: SpawnAcpSandboxMode;
streamTo?: SpawnAcpStreamTarget;
attachments?: AcpTurnAttachment[];
};
type GatewayImageAttachmentInput = {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
};
function toGatewayImageAttachments(
attachments: AcpTurnAttachment[] | undefined,
): GatewayImageAttachmentInput[] | undefined {
if (!attachments || attachments.length === 0) {
return undefined;
}
return attachments.map((attachment) => ({
type: "image",
source: {
type: "base64",
media_type: attachment.mediaType,
data: attachment.data,
},
}));
}
export type SpawnAcpContext = {
agentSessionKey?: string;
agentChannel?: string;
@@ -1445,6 +1472,7 @@ export async function spawnAcpDirect(
emitStartNotice: false,
});
}
const gatewayAttachments = toGatewayImageAttachments(params.attachments);
try {
const response = await callGateway({
method: "agent",
@@ -1461,6 +1489,7 @@ export async function spawnAcpDirect(
acpTurnSource: "manual_spawn",
...(params.runTimeoutSeconds != null ? { timeout: params.runTimeoutSeconds } : {}),
label: params.label || undefined,
...(gatewayAttachments ? { attachments: gatewayAttachments } : {}),
},
timeoutMs: 10_000,
});

View File

@@ -1,3 +1,4 @@
import { resolveInlineAgentImageAttachments } from "../auto-reply/reply/agent-turn-attachments.js";
import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js";
import {
formatThinkingLevels,
@@ -638,10 +639,12 @@ async function agentCommandInternal(
throw agentPolicyError;
}
const acpImageAttachments = resolveInlineAgentImageAttachments(opts.images);
await acpManager.runTurn({
cfg,
sessionKey,
text: body,
attachments: acpImageAttachments.length > 0 ? acpImageAttachments : undefined,
mode: "prompt",
requestId: runId,
signal: opts.abortSignal,

View File

@@ -28,13 +28,18 @@ export function decodeStrictBase64(value: string, maxDecodedBytes: number): Buff
return decoded;
}
type SubagentInlineAttachment = {
export type SubagentInlineAttachment = {
name: string;
content: string;
encoding?: "utf8" | "base64";
mimeType?: string;
};
type AcpInlineImageAttachment = {
mediaType: string;
data: string;
};
type AttachmentLimits = {
enabled: boolean;
maxTotalBytes: number;
@@ -94,6 +99,120 @@ function resolveAttachmentLimits(config: OpenClawConfig): AttachmentLimits {
};
}
export function resolveAcpSessionsSpawnImageAttachments(params: {
config: OpenClawConfig;
attachments?: SubagentInlineAttachment[];
}):
| { status: "ok"; attachments: AcpInlineImageAttachment[] }
| { status: "forbidden"; error: string }
| { status: "error"; error: string }
| null {
const requestedAttachments = Array.isArray(params.attachments) ? params.attachments : [];
if (requestedAttachments.length === 0) {
return null;
}
const limits = resolveAttachmentLimits(params.config);
if (!limits.enabled) {
return {
status: "forbidden",
error:
"attachments are disabled for sessions_spawn (enable tools.sessions_spawn.attachments.enabled)",
};
}
if (requestedAttachments.length > limits.maxFiles) {
return {
status: "error",
error: `attachments_file_count_exceeded (maxFiles=${limits.maxFiles})`,
};
}
const fail = (error: string): never => {
throw new Error(error);
};
try {
const seen = new Set<string>();
const attachments: AcpInlineImageAttachment[] = [];
let totalBytes = 0;
for (const raw of requestedAttachments) {
const name = normalizeOptionalString(raw?.name) ?? "";
const contentVal = typeof raw?.content === "string" ? raw.content : "";
const encodingRaw = normalizeOptionalString(raw?.encoding) ?? "utf8";
const encoding = encodingRaw === "base64" ? "base64" : "utf8";
const mimeType = normalizeOptionalString(raw?.mimeType) ?? "";
if (!name) {
fail("attachments_invalid_name (empty)");
}
if (name.includes("/") || name.includes("\\") || name.includes("\u0000")) {
fail(`attachments_invalid_name (${name})`);
}
if (
Array.from(name).some((char) => {
const code = char.codePointAt(0) ?? 0;
return code < 0x20 || code === 0x7f;
})
) {
fail(`attachments_invalid_name (${name})`);
}
if (name === "." || name === ".." || name === ".manifest.json") {
fail(`attachments_invalid_name (${name})`);
}
if (seen.has(name)) {
fail(`attachments_duplicate_name (${name})`);
}
seen.add(name);
if (!mimeType.startsWith("image/")) {
fail(`attachments_unsupported_for_acp (name=${name} mimeType=${mimeType || "unknown"})`);
}
let buf: Buffer;
if (encoding === "base64") {
const strictBuf = decodeStrictBase64(contentVal, limits.maxFileBytes);
if (strictBuf === null) {
throw new Error("attachments_invalid_base64_or_too_large");
}
buf = strictBuf;
} else {
const estimatedBytes = Buffer.byteLength(contentVal, "utf8");
if (estimatedBytes > limits.maxFileBytes) {
fail(
`attachments_file_bytes_exceeded (name=${name} bytes=${estimatedBytes} maxFileBytes=${limits.maxFileBytes})`,
);
}
buf = Buffer.from(contentVal, "utf8");
}
const bytes = buf.byteLength;
if (bytes > limits.maxFileBytes) {
fail(
`attachments_file_bytes_exceeded (name=${name} bytes=${bytes} maxFileBytes=${limits.maxFileBytes})`,
);
}
totalBytes += bytes;
if (totalBytes > limits.maxTotalBytes) {
fail(
`attachments_total_bytes_exceeded (totalBytes=${totalBytes} maxTotalBytes=${limits.maxTotalBytes})`,
);
}
attachments.push({
mediaType: mimeType,
data: buf.toString("base64"),
});
}
return { status: "ok", attachments };
} catch (err) {
return {
status: "error",
error: err instanceof Error ? err.message : "attachments_materialization_failed",
};
}
}
export async function materializeSubagentAttachments(params: {
config: OpenClawConfig;
targetAgentId: string;

View File

@@ -859,7 +859,29 @@ describe("sessions_spawn tool", () => {
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("rejects attachments for ACP runtime", async () => {
it("rejects ACP attachments when sessions_spawn attachments are disabled", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: {} as never,
});
const imageBase64 = Buffer.from("png-bytes").toString("base64");
const result = await tool.execute("call-3", {
runtime: "acp",
task: "describe the image",
attachments: [
{ name: "photo.png", content: imageBase64, encoding: "base64", mimeType: "image/png" },
],
});
expectDetailFields(result.details, { status: "forbidden" });
expect(JSON.stringify(result.details)).toContain("attachments are disabled");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("forwards validated image attachments for ACP runtime", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@@ -867,19 +889,95 @@ describe("sessions_spawn tool", () => {
agentAccountId: "default",
agentTo: "channel:123",
agentThreadId: "456",
config: {
tools: {
sessions_spawn: {
attachments: {
enabled: true,
maxFiles: 1,
maxFileBytes: 32,
maxTotalBytes: 32,
},
},
},
} as never,
});
const imageBase64 = Buffer.from("png-bytes").toString("base64");
const result = await tool.execute("call-3", {
runtime: "acp",
task: "analyze file",
attachments: [{ name: "a.txt", content: "hello", encoding: "utf8" }],
task: "describe the image",
attachments: [
{ name: "photo.png", content: imageBase64, encoding: "base64", mimeType: "image/png" },
],
});
expect(result.details).toMatchObject({
status: "accepted",
});
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "describe the image",
attachments: [{ mediaType: "image/png", data: imageBase64 }],
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("rejects non-image ACP attachments", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: {
tools: { sessions_spawn: { attachments: { enabled: true } } },
} as never,
});
const result = await tool.execute("call-acp-non-image", {
runtime: "acp",
task: "read text",
attachments: [
{ name: "note.txt", content: "hello", encoding: "utf8", mimeType: "text/plain" },
],
});
expectDetailFields(result.details, { status: "error" });
const details = result.details as { error?: string };
expect(details.error).toContain("attachments are currently unsupported for runtime=acp");
expect(JSON.stringify(result.details)).toContain("attachments_unsupported_for_acp");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("enforces ACP attachment size limits", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: {
tools: {
sessions_spawn: {
attachments: {
enabled: true,
maxFiles: 1,
maxFileBytes: 4,
maxTotalBytes: 4,
},
},
},
} as never,
});
const result = await tool.execute("call-acp-too-large", {
runtime: "acp",
task: "describe the image",
attachments: [
{ name: "photo.png", content: "too large", encoding: "utf8", mimeType: "image/png" },
],
});
expectDetailFields(result.details, { status: "error" });
expect(JSON.stringify(result.details)).toContain("attachments_file_bytes_exceeded");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it('ignores streamTo when runtime is omitted and defaults to "subagent"', async () => {

View File

@@ -18,6 +18,7 @@ import {
} from "../inherited-tool-deny.js";
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { resolveAcpSessionsSpawnImageAttachments } from "../subagent-attachments.js";
import { registerSubagentRun } from "../subagent-registry.js";
import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js";
import {
@@ -359,11 +360,14 @@ export function createSessionsSpawnTool(
if (runtime === "acp") {
const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await loadAcpSpawnModule();
if (Array.isArray(attachments) && attachments.length > 0) {
const acpAttachments = resolveAcpSessionsSpawnImageAttachments({
config: opts?.config ?? getRuntimeConfig(),
attachments,
});
if (acpAttachments?.status === "forbidden" || acpAttachments?.status === "error") {
return jsonResult({
status: "error",
error:
"attachments are currently unsupported for runtime=acp; use runtime=subagent or remove attachments",
status: acpAttachments.status,
error: acpAttachments.error,
...roleContext,
});
}
@@ -381,6 +385,7 @@ export function createSessionsSpawnTool(
thread,
sandbox,
streamTo,
attachments: acpAttachments?.attachments,
},
{
agentSessionKey: opts?.agentSessionKey,

View File

@@ -369,6 +369,17 @@ export type FsToolsConfig = {
workspaceOnly?: boolean;
};
export type SessionsSpawnToolsConfig = {
attachments?: {
/** Enable inline attachments for sessions_spawn. */
enabled?: boolean;
maxTotalBytes?: number;
maxFiles?: number;
maxFileBytes?: number;
retainOnSessionKeep?: boolean;
};
};
export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
@@ -700,6 +711,8 @@ export type ToolsConfig = {
toolSearch?: ToolSearchConfig;
/** Generic code mode: expose exec/wait and hide normal tools behind a QuickJS catalog bridge. */
codeMode?: CodeModeConfig;
/** sessions_spawn tool configuration. */
sessions_spawn?: SessionsSpawnToolsConfig;
/** Sub-agent tool policy defaults (deny wins). */
subagents?: {
tools?: {

View File

@@ -13,16 +13,14 @@ describe("tryCronScheduleIdentity", () => {
});
expect(stringNumeric).toBe(numeric);
const stringNumericInput = {
schedule: { kind: "every", everyMs: "60000", anchorMs: "123" },
} as unknown as Parameters<typeof cronSchedulingInputsEqual>[1];
expect(
cronSchedulingInputsEqual(
{ schedule: { kind: "every", everyMs: 60_000, anchorMs: 123 } },
{
schedule: {
kind: "every",
everyMs: "60000" as unknown as number,
anchorMs: "123" as unknown as number,
},
},
stringNumericInput,
),
).toBe(true);
});

View File

@@ -39,7 +39,10 @@ const LIVE = isLiveTestEnabled();
const ACP_BIND_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND);
const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
const CONNECT_TIMEOUT_MS = 90_000;
const CONNECT_TIMEOUT_MS = resolveLiveTimeoutMs(
process.env.OPENCLAW_LIVE_ACP_BIND_REQUEST_TIMEOUT_MS,
90_000,
);
const LIVE_TIMEOUT_MS = 240_000;
const ACP_CRON_MCP_PROBE_MAX_ATTEMPTS = 2;
const ACP_CRON_MCP_PROBE_VERIFY_POLLS = 5;
@@ -48,6 +51,11 @@ const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
type LiveAcpAgent = "claude" | "codex" | "droid" | "gemini" | "opencode";
function resolveLiveTimeoutMs(raw: string | undefined, fallback: number): number {
const parsed = raw ? Number(raw) : Number.NaN;
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
class AcpBindSkipError extends Error {
override readonly name = "AcpBindSkipError";
}

View File

@@ -1050,6 +1050,72 @@ describe("gateway agent handler", () => {
});
});
it("does not bypass image support check for non-ACP sessions with acpTurnSource", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "describe this image",
agentId: "main",
sessionKey: "agent:main:main",
acpTurnSource: "manual_spawn",
idempotencyKey: "test-acp-image-bypass-guard",
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "test.png",
content: Buffer.from("fake-png-data").toString("base64"),
},
],
},
{ respond, reqId: "test-acp-image-bypass-guard" },
);
// Non-ACP session (agent:main:main) with acpTurnSource="manual_spawn" must
// NOT bypass resolveGatewayModelSupportsImages. The image should be rejected
// by the normal image-support check since this is not an ACP session.
expect(mocks.agentCommand).not.toHaveBeenCalled();
const error = expectRespondError(respond, {});
expectStringFieldContains(error, "message", "does not accept image inputs");
});
it("does not bypass image support check for ACP-shaped sessions without ACP metadata", async () => {
mockMainSessionEntry({ sessionId: "existing-acp-shaped-session" });
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "describe this image",
agentId: "main",
sessionKey: "agent:main:acp:missing-meta",
acpTurnSource: "manual_spawn",
idempotencyKey: "test-acp-image-metadata-bypass-guard",
attachments: [
{
type: "file",
mimeType: "image/png",
fileName: "test.png",
content: Buffer.from("fake-png-data").toString("base64"),
},
],
},
{ respond, reqId: "test-acp-image-metadata-bypass-guard" },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
const error = expectRespondError(respond, {});
expectStringFieldContains(error, "message", "does not accept image inputs");
});
it("rejects provider and model overrides for write-scoped callers", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();

View File

@@ -1094,10 +1094,12 @@ export const agentHandlers: GatewayRequestHandlers = {
if (normalizedAttachments.length > 0) {
let baseProvider: string | undefined;
let baseModel: string | undefined;
let requestedSessionEntry: SessionEntry | undefined;
if (requestedSessionKeyRaw) {
const { cfg: sessCfg, entry: sessEntry } = loadSessionEntry(requestedSessionKeyRaw, {
clone: false,
});
requestedSessionEntry = sessEntry;
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
const modelRef = resolveSessionModelRef(sessCfg, sessEntry, sessionAgentId);
baseProvider = modelRef.provider;
@@ -1105,11 +1107,17 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const effectiveProvider = providerOverride || baseProvider;
const effectiveModel = modelOverride || baseModel;
const supportsInlineImages = await resolveGatewayModelSupportsImages({
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
provider: effectiveProvider,
model: effectiveModel,
});
const isConfirmedAcpSession =
request.acpTurnSource === "manual_spawn" &&
isAcpSessionKey(requestedSessionKeyRaw) &&
requestedSessionEntry?.acp != null;
const supportsInlineImages = isConfirmedAcpSession
? true
: await resolveGatewayModelSupportsImages({
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
provider: effectiveProvider,
model: effectiveModel,
});
try {
const parsed = await parseMessageWithAttachments(message, normalizedAttachments, {