mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user