From 74349e2500dae699038ad3906ac76751f6e51c7c Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 2 Jun 2026 18:19:02 +0200 Subject: [PATCH] fix(workflow-executor): sign agent JWT with snake_case identity claims (PRD-432) The executor mints the JWT it sends to the agent from StepUser (camelCase). Ruby/Python agents splat JWT claims straight into Caller.new, which requires snake_case kwargs (first_name, last_name, rendering_id, permission_level), so the call failed with a 500 ArgumentError. The TS agent reads camelCase. There is no single canonical casing, so emit both: the Ruby Caller absorbs the extra camelCase keys via **_extra_args and the TS agent ignores the snake_case ones. fixes PRD-432 Co-Authored-By: Claude Opus 4.8 --- .../src/adapters/agent-client-agent-port.ts | 16 ++++++++++--- .../adapters/agent-client-agent-port.test.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 24bec25231..6744ab7f51 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -179,9 +179,19 @@ export default class AgentClientAgentPort implements AgentPort { } private createClient(user: StepUser) { - const token = jsonwebtoken.sign({ ...user, scope: 'step-execution' }, this.authSecret, { - expiresIn: '5m', - }); + // snake_case aliases: Ruby/Python agents splat JWT claims into Caller.new (snake_case kwargs). + const token = jsonwebtoken.sign( + { + ...user, + first_name: user.firstName, + last_name: user.lastName, + rendering_id: user.renderingId, + permission_level: user.permissionLevel, + scope: 'step-execution', + }, + this.authSecret, + { expiresIn: '5m' }, + ); return createRemoteAgentClient({ url: this.agentUrl, diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index aacaff0652..5cc0fe26dc 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -1,6 +1,7 @@ import type { StepUser } from '../../src/types/execution-context'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; +import jsonwebtoken from 'jsonwebtoken'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; import { AgentProbeError, RecordNotFoundError } from '../../src/errors'; @@ -202,6 +203,29 @@ describe('AgentClientAgentPort', () => { }); }); + describe('agent JWT', () => { + it('signs both camelCase and snake_case identity claims for cross-runtime agents', async () => { + mockCollection.list.mockResolvedValue([{ id: 42 }]); + + await port.getRecord({ collection: 'users', id: [42] }, user); + + const { token } = mockedCreateRemoteAgentClient.mock.calls[0][0]; + const payload = jsonwebtoken.verify(token, 'test-secret') as Record; + + expect(payload).toMatchObject({ + firstName: 'Test', + lastName: 'User', + renderingId: 1, + permissionLevel: 'admin', + first_name: 'Test', + last_name: 'User', + rendering_id: 1, + permission_level: 'admin', + scope: 'step-execution', + }); + }); + }); + describe('updateRecord', () => { it('should forward the RecordId array to agent-client and return a RecordData', async () => { mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' });