Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions packages/backend/src/adapters/intl/odoo.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"X-Odoo-Database": "{{ODOO_DB}}",
"Content-Type": "application/json"
},
"healthcheckPath": "/res.users/search_count"
"healthcheckPath": "/../../doc-bearer/res.country.state.json"
},
"probe": {
"tool": "odoo_list_partners"
Expand Down Expand Up @@ -134,14 +134,15 @@
},
"domain": {
"type": "array",
"description": "Odoo domain as a list of triples.",
"description": "Odoo domain as a list of triples. Required by Odoo; pass [] to count every record.",
"items": {
"type": "array"
}
}
},
"required": [
"model"
"model",
"domain"
]
},
"endpointMapping": {
Expand Down Expand Up @@ -363,7 +364,7 @@
},
{
"name": "odoo_create",
"description": "Create a record in any Odoo model. Writes to the live database and fires Odoo's automations — mail, stock moves and accounting entries all behave as if a person had done it.",
"description": "Create a record in any Odoo model and return its id (as a one-element list). Writes to the live database and fires Odoo's automations — mail, stock moves and accounting entries all behave as if a person had done it.",
"parameters": {
"type": "object",
"properties": {
Expand All @@ -385,7 +386,9 @@
"method": "POST",
"path": "/{model}/create",
"bodyMapping": {
"values": "$values"
"vals_list": [
"$values"
]
}
}
},
Expand Down Expand Up @@ -422,7 +425,7 @@
"path": "/{model}/write",
"bodyMapping": {
"ids": "$ids",
"values": "$values"
"vals": "$values"
}
}
},
Expand All @@ -442,14 +445,14 @@
},
"ids": {
"type": "array",
"description": "Record ids the method operates on.",
"description": "Record ids the method operates on. Use [] for model-level methods such as name_search or create.",
"items": {
"type": "number"
}
},
"kwargs": {
"type": "object",
"description": "Keyword arguments for the method, when it takes any."
"description": "The method's keyword arguments, by name; they are sent as top-level keys of the request, which is how Odoo binds them. E.g. {\"summary\": \"Call back\", \"date_deadline\": \"2026-10-16\", \"activity_type_id\": 2} for activity_schedule on crm.lead. Omit for methods that take only the records, such as action_confirm."
}
},
"required": [
Expand All @@ -463,7 +466,7 @@
"path": "/{model}/{method}",
"bodyMapping": {
"ids": "$ids",
"kwargs": "$kwargs"
"__merge": "$kwargs"
}
}
}
Expand Down
34 changes: 34 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,40 @@ describe('RestEngine', () => {
);
});

it('spreads a __merge object into the top level of the body, explicit keys first', async () => {
mockedAxios.mockResolvedValue({ data: [] });
const mapping = {
method: 'POST',
path: '/json/2/{model}/{method}',
bodyMapping: { ids: '$ids', __merge: '$kwargs' },
};

await engine.execute(
{ baseUrl: 'https://odoo.example.com', authType: 'NONE' },
mapping,
{ model: 'crm.lead', method: 'activity_schedule', ids: [529], kwargs: { summary: 'Call', ids: [1] } },
);
expect(mockedAxios).toHaveBeenLastCalledWith(
expect.objectContaining({ data: { ids: [529], summary: 'Call' } }),
);

// Absent: nothing merged, no stray key.
await engine.execute(
{ baseUrl: 'https://odoo.example.com', authType: 'NONE' },
mapping,
{ model: 'sale.order', method: 'action_confirm', ids: [4] },
);
expect(mockedAxios).toHaveBeenLastCalledWith(expect.objectContaining({ data: { ids: [4] } }));

await expect(
engine.execute(
{ baseUrl: 'https://odoo.example.com', authType: 'NONE' },
mapping,
{ model: 'x', method: 'y', ids: [], kwargs: JSON.parse('{"__proto__": {"polluted": true}}') },
),
).rejects.toThrow(/prototype pollution/);
});

it('should drop missing params from nested bodyMapping instead of sending "$TERM" literals', async () => {
mockedAxios.mockResolvedValue({ data: {} });

Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,11 +712,30 @@ export class RestEngine {
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(mapping)) {
if (key === '__merge') continue;
const resolved = this.resolveValue(value, params);
if (resolved !== undefined) {
result[key] = resolved;
}
}
// `__merge`: an object argument whose keys become top-level keys of the
// body. Some APIs take a method's arguments as the body itself (Odoo's
// JSON-2 `/json/2/<model>/<method>` binds every top-level key to a
// parameter of the method), so a generic "call a method" tool cannot know
// the keys in advance. Keys mapped explicitly win over merged ones.
if ('__merge' in mapping) {
const extra = this.resolveValue(mapping['__merge'], params);
if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
assertNoPrototypePollution(extra);
// fromEntries creates own data properties only, as in safeEntries.
const merged = Object.fromEntries(
Object.entries(extra as Record<string, unknown>).filter(
([key]) => !isUnsafeKey(key) && !Object.hasOwn(result, key),
),
);
return { ...merged, ...result };
}
}
return result;
}

Expand Down
Loading