fix: prevent nodes media base64 context bloat (#34332)

This commit is contained in:
Ayaan Zaidi
2026-03-04 16:53:16 +05:30
committed by Ayaan Zaidi
parent ed8e0a8146
commit ef4fa43df8
4 changed files with 338 additions and 15 deletions

View File

@@ -32,16 +32,21 @@ function unexpectedGatewayMethod(method: unknown): never {
throw new Error(`unexpected method: ${String(method)}`);
}
function getNodesTool() {
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
function getNodesTool(options?: { modelHasVision?: boolean }) {
const tool = createOpenClawTools(
options?.modelHasVision !== undefined ? { modelHasVision: options.modelHasVision } : {},
).find((candidate) => candidate.name === "nodes");
if (!tool) {
throw new Error("missing nodes tool");
}
return tool;
}
async function executeNodes(input: Record<string, unknown>) {
return getNodesTool().execute("call1", input as never);
async function executeNodes(
input: Record<string, unknown>,
options?: { modelHasVision?: boolean },
) {
return getNodesTool(options).execute("call1", input as never);
}
type NodesToolResult = Awaited<ReturnType<typeof executeNodes>>;
@@ -67,6 +72,11 @@ function expectSingleImage(result: NodesToolResult, params?: { mimeType?: string
}
}
function expectNoImages(result: NodesToolResult) {
const images = (result.content ?? []).filter((block) => block.type === "image");
expect(images).toHaveLength(0);
}
function expectFirstTextContains(result: NodesToolResult, expectedText: string) {
expect(result.content?.[0]).toMatchObject({
type: "text",
@@ -156,10 +166,13 @@ describe("nodes camera_snap", () => {
},
});
const result = await executeNodes({
action: "camera_snap",
node: NODE_ID,
});
const result = await executeNodes(
{
action: "camera_snap",
node: NODE_ID,
},
{ modelHasVision: true },
);
expectSingleImage(result);
});
@@ -169,15 +182,39 @@ describe("nodes camera_snap", () => {
invokePayload: JPG_PAYLOAD,
});
const result = await executeNodes({
action: "camera_snap",
node: NODE_ID,
facing: "front",
});
const result = await executeNodes(
{
action: "camera_snap",
node: NODE_ID,
facing: "front",
},
{ modelHasVision: true },
);
expectSingleImage(result, { mimeType: "image/jpeg" });
});
it("omits inline base64 image blocks when model has no vision", async () => {
setupNodeInvokeMock({
invokePayload: JPG_PAYLOAD,
});
const result = await executeNodes(
{
action: "camera_snap",
node: NODE_ID,
facing: "front",
},
{ modelHasVision: false },
);
expectNoImages(result);
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringMatching(/^MEDIA:/),
});
});
it("passes deviceId when provided", async () => {
setupNodeInvokeMock({
onInvoke: (invokeParams) => {
@@ -299,6 +336,130 @@ describe("nodes camera_clip", () => {
});
});
describe("nodes photos_latest", () => {
it("returns empty content/details when no photos are available", async () => {
setupNodeInvokeMock({
onInvoke: (invokeParams) => {
expect(invokeParams).toMatchObject({
command: "photos.latest",
params: {
limit: 1,
maxWidth: 1600,
quality: 0.85,
},
});
return {
payload: {
photos: [],
},
};
},
});
const result = await executeNodes(
{
action: "photos_latest",
node: NODE_ID,
},
{ modelHasVision: false },
);
expect(result.content ?? []).toEqual([]);
expect(result.details).toEqual([]);
});
it("returns MEDIA paths and no inline images when model has no vision", async () => {
setupNodeInvokeMock({
remoteIp: "198.51.100.42",
onInvoke: (invokeParams) => {
expect(invokeParams).toMatchObject({
command: "photos.latest",
params: {
limit: 1,
maxWidth: 1600,
quality: 0.85,
},
});
return {
payload: {
photos: [
{
format: "jpeg",
base64: "aGVsbG8=",
width: 1,
height: 1,
createdAt: "2026-03-04T00:00:00Z",
},
],
},
};
},
});
const result = await executeNodes(
{
action: "photos_latest",
node: NODE_ID,
},
{ modelHasVision: false },
);
expectNoImages(result);
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringMatching(/^MEDIA:/),
});
const details = Array.isArray(result.details) ? result.details : [];
expect(details[0]).toMatchObject({
width: 1,
height: 1,
createdAt: "2026-03-04T00:00:00Z",
});
});
it("includes inline image blocks when model has vision", async () => {
setupNodeInvokeMock({
onInvoke: (invokeParams) => {
expect(invokeParams).toMatchObject({
command: "photos.latest",
params: {
limit: 1,
maxWidth: 1600,
quality: 0.85,
},
});
return {
payload: {
photos: [
{
format: "jpeg",
base64: "aGVsbG8=",
width: 1,
height: 1,
createdAt: "2026-03-04T00:00:00Z",
},
],
},
};
},
});
const result = await executeNodes(
{
action: "photos_latest",
node: NODE_ID,
},
{ modelHasVision: true },
);
expect(result.content?.[0]).toMatchObject({
type: "text",
text: expect.stringMatching(/^MEDIA:/),
});
expectSingleImage(result, { mimeType: "image/jpeg" });
});
});
describe("nodes notifications_list", () => {
it("invokes notifications.list and returns payload", async () => {
setupNodeInvokeMock({
@@ -576,3 +737,44 @@ describe("nodes run", () => {
);
});
});
describe("nodes invoke", () => {
it("allows metadata-only camera.list via generic invoke", async () => {
setupNodeInvokeMock({
onInvoke: (invokeParams) => {
expect(invokeParams).toMatchObject({
command: "camera.list",
params: {},
});
return {
payload: {
devices: [{ id: "cam-back", name: "Back Camera" }],
},
};
},
});
const result = await executeNodes({
action: "invoke",
node: NODE_ID,
invokeCommand: "camera.list",
});
expect(result.details).toMatchObject({
payload: {
devices: [{ id: "cam-back", name: "Back Camera" }],
},
});
});
it("blocks media invoke commands to avoid base64 context bloat", async () => {
await expect(
executeNodes({
action: "invoke",
node: NODE_ID,
invokeCommand: "photos.latest",
invokeParamsJson: '{"limit":1}',
}),
).rejects.toThrow(/use action="photos_latest"/i);
});
});