diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 2fba92d687e2..76e447e4f07a 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -386,12 +386,13 @@ Controls inline attachment support for `sessions_spawn`. - - Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them. - - Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json`. + - Attachments require `enabled: true`. + - Subagent attachments are materialized into the child workspace at `.openclaw/attachments//` 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`. diff --git a/extensions/azure-speech/speech-provider.test.ts b/extensions/azure-speech/speech-provider.test.ts index 55b525d4d7b7..30854a508b54 100644 --- a/extensions/azure-speech/speech-provider.test.ts +++ b/extensions/azure-speech/speech-provider.test.ts @@ -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); }); diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 168a1ab1f044..d4af56f4bdda 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -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(), diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 35df02cbf47e..6055f88573d7 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -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, }); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 431b6897250e..979044ac7e36 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -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, diff --git a/src/agents/subagent-attachments.ts b/src/agents/subagent-attachments.ts index 69bf47123d6b..13f4b68702cd 100644 --- a/src/agents/subagent-attachments.ts +++ b/src/agents/subagent-attachments.ts @@ -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(); + 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; diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 91798b1aa042..0b3496db4cfc 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -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 () => { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 404f2557f9b0..f1347896d639 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -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, diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 0cf0e67bfa9d..b11c38765823 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -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?: { diff --git a/src/cron/schedule-identity.test.ts b/src/cron/schedule-identity.test.ts index 5e091133bc29..8685c2b6513f 100644 --- a/src/cron/schedule-identity.test.ts +++ b/src/cron/schedule-identity.test.ts @@ -13,16 +13,14 @@ describe("tryCronScheduleIdentity", () => { }); expect(stringNumeric).toBe(numeric); + const stringNumericInput = { + schedule: { kind: "every", everyMs: "60000", anchorMs: "123" }, + } as unknown as Parameters[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); }); diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index d0218668ce06..c95eb8eb62ee 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -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"; } diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index eb5ae407dcfa..b67622a1760b 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -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(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 7907218d6afc..73482a503740 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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, {