diff --git a/packages/backend/src/adapters/intl/odoo.json b/packages/backend/src/adapters/intl/odoo.json index 57b10b64..0cb3a570 100644 --- a/packages/backend/src/adapters/intl/odoo.json +++ b/packages/backend/src/adapters/intl/odoo.json @@ -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" @@ -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": { @@ -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": { @@ -385,7 +386,9 @@ "method": "POST", "path": "/{model}/create", "bodyMapping": { - "values": "$values" + "vals_list": [ + "$values" + ] } } }, @@ -422,7 +425,7 @@ "path": "/{model}/write", "bodyMapping": { "ids": "$ids", - "values": "$values" + "vals": "$values" } } }, @@ -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": [ @@ -463,7 +466,7 @@ "path": "/{model}/{method}", "bodyMapping": { "ids": "$ids", - "kwargs": "$kwargs" + "__merge": "$kwargs" } } } diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index 65f0d567..bdcdef30 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -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: {} }); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index f15453dc..b6c4940c 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -712,11 +712,30 @@ export class RestEngine { } const result: Record = {}; 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//` 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).filter( + ([key]) => !isUnsafeKey(key) && !Object.hasOwn(result, key), + ), + ); + return { ...merged, ...result }; + } + } return result; }