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, {