diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index c8030867db..277d2c05c9 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -4727,6 +4727,7 @@ input CreateEmailingDomainInput {
input RunAgentInput {
agentUniversalIdentifier: String!
prompt: String
+ runAsWorkspaceMemberId: UUID
messages: [RunAgentMessageInput!]
}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 4498a40996..7d8181496f 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -6939,7 +6939,7 @@ export interface UpdateEmailGroupChannelInput {id: Scalars['UUID'],displayName?:
export interface CreateEmailingDomainInput {domain: Scalars['String']}
-export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),messages?: (RunAgentMessageInput[] | null)}
+export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),runAsWorkspaceMemberId?: (Scalars['UUID'] | null),messages?: (RunAgentMessageInput[] | null)}
export interface RunAgentMessageInput {role: RunAgentMessageRole,content: Scalars['String']}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 7b26a8ede3..adcbfa268f 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -12081,6 +12081,9 @@ export default {
"prompt": [
1
],
+ "runAsWorkspaceMemberId": [
+ 4
+ ],
"messages": [
493
],
diff --git a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
index 15f071fdff..6237863f2c 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
@@ -164,5 +164,44 @@ export default defineApplicationRole({
guard on whether any target field is still empty before calling `runAgent()`.
+### Running on behalf of a workspace member
+
+Pass `runAsWorkspaceMemberId` when the run is triggered by a person — a chat
+bot answering a message, for instance — so the agent acts as that member
+instead of as the app:
+
+```ts src/logic-functions/answer-question.ts
+const { result } = await runAgent({
+ agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
+ prompt: 'How many open opportunities do we have?',
+ runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
+});
+```
+
+The run is then restricted to what **both** the agent's role and the member's
+role allow, records it creates are attributed to that member, and the member's
+row-level permissions apply. Omit the field for autonomous runs (scheduled
+jobs, database-event triggers): those keep the agent's own role.
+
+Your app is responsible for mapping the person who triggered the run to a
+workspace member. Naming one requires an app access token, and what a token may
+name depends on whether it carries a user:
+
+- A token with **no user attached** may name any member. A logic function runs
+ with one, and so do tokens minted through `client_credentials` or from an API
+ key.
+- A token issued **on behalf of a user**, as a front component receives, may
+ only name that user's own member.
+
+Anything else — a plain user session, an API key without an app token — may not
+name a member at all.
+
+
+ `runAgent()` throws when the workspace member cannot be resolved — an
+ unknown or removed member, or one with no role. It never falls back to the
+ agent's own role, since that would grant more access than the caller asked
+ for.
+
+
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index c0dadf3e41..6a3d069019 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -5239,6 +5239,7 @@ export type RunAgentInput = {
agentUniversalIdentifier: Scalars['String']['input'];
messages?: InputMaybe>;
prompt?: InputMaybe;
+ runAsWorkspaceMemberId?: InputMaybe;
};
export type RunAgentMessageInput = {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
index e4217f52e1..baba54800e 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
@@ -11,6 +11,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-t
import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -432,6 +433,182 @@ describe('JwtAuthStrategy', () => {
expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND);
}
});
+
+ it('should reject an application token bound to a user that cannot be resolved', async () => {
+ const validApplicationId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const removedUserId = randomUUID();
+
+ const payload = {
+ sub: validApplicationId,
+ type: JwtTokenTypeEnum.APPLICATION_ACCESS,
+ applicationId: validApplicationId,
+ workspaceId: validWorkspaceId,
+ userId: removedUserId,
+ userWorkspaceId: randomUUID(),
+ };
+
+ const mockWorkspace = new WorkspaceEntity();
+
+ mockWorkspace.id = validWorkspaceId;
+ workspaceStore[validWorkspaceId] = mockWorkspace;
+ applicationStore[validWorkspaceId] = {
+ [validApplicationId]: { id: validApplicationId },
+ };
+
+ strategy = createStrategy();
+
+ try {
+ await strategy.validate(payload as JwtPayload);
+ throw new Error('Expected validate to reject');
+ } catch (e) {
+ expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
+ }
+ });
+
+ it('should reject an application token whose user is no longer a workspace member', async () => {
+ const validApplicationId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+
+ const payload = {
+ sub: validApplicationId,
+ type: JwtTokenTypeEnum.APPLICATION_ACCESS,
+ applicationId: validApplicationId,
+ workspaceId: validWorkspaceId,
+ userId: validUserId,
+ userWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new WorkspaceEntity();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE;
+ workspaceStore[validWorkspaceId] = mockWorkspace;
+ applicationStore[validWorkspaceId] = {
+ [validApplicationId]: { id: validApplicationId },
+ };
+ userStore[validUserId] = { id: validUserId };
+
+ coreEntityCacheService.get.mockImplementation(
+ async (keyName: string, entityId: string) => {
+ if (keyName === 'workspaceEntity') {
+ return workspaceStore[entityId] ?? null;
+ }
+
+ if (keyName === 'user') {
+ return userStore[entityId] ?? null;
+ }
+
+ if (keyName === 'userWorkspaceEntity') {
+ return {
+ id: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ user: { id: validUserId },
+ workspace: { id: validWorkspaceId },
+ };
+ }
+
+ return null;
+ },
+ );
+
+ strategy = createStrategy();
+
+ try {
+ await strategy.validate(payload as JwtPayload);
+ throw new Error('Expected validate to reject');
+ } catch (e) {
+ expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION);
+ }
+ });
+
+ it('should reject an application token whose workspace member is soft-deleted', async () => {
+ const validApplicationId = randomUUID();
+ const validWorkspaceId = randomUUID();
+ const validUserId = randomUUID();
+ const validUserWorkspaceId = randomUUID();
+ const validWorkspaceMemberId = randomUUID();
+
+ const payload = {
+ sub: validApplicationId,
+ type: JwtTokenTypeEnum.APPLICATION_ACCESS,
+ applicationId: validApplicationId,
+ workspaceId: validWorkspaceId,
+ userId: validUserId,
+ userWorkspaceId: validUserWorkspaceId,
+ };
+
+ const mockWorkspace = new WorkspaceEntity();
+
+ mockWorkspace.id = validWorkspaceId;
+ mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE;
+ workspaceStore[validWorkspaceId] = mockWorkspace;
+ applicationStore[validWorkspaceId] = {
+ [validApplicationId]: { id: validApplicationId },
+ };
+ userStore[validUserId] = { id: validUserId };
+
+ workspaceCacheService.getOrRecompute.mockImplementation(
+ async (workspaceId: string, cacheKeys: string[]) => {
+ const result: Record = {};
+
+ if (cacheKeys.includes('flatWorkspaceMemberMaps')) {
+ result.flatWorkspaceMemberMaps = {
+ byId: {
+ [validWorkspaceMemberId]: {
+ id: validWorkspaceMemberId,
+ userId: validUserId,
+ deletedAt: new Date(),
+ },
+ },
+ idByUserId: { [validUserId]: validWorkspaceMemberId },
+ };
+ }
+
+ if (cacheKeys.includes('flatApplicationMaps')) {
+ result.flatApplicationMaps = {
+ byId: applicationStore[workspaceId] ?? {},
+ };
+ }
+
+ return result;
+ },
+ );
+
+ coreEntityCacheService.get.mockImplementation(
+ async (keyName: string, entityId: string) => {
+ if (keyName === 'workspaceEntity') {
+ return workspaceStore[entityId] ?? null;
+ }
+
+ if (keyName === 'user') {
+ return userStore[entityId] ?? null;
+ }
+
+ if (keyName === 'userWorkspaceEntity') {
+ return {
+ id: validUserWorkspaceId,
+ workspaceId: validWorkspaceId,
+ user: { id: validUserId },
+ workspace: { id: validWorkspaceId },
+ };
+ }
+
+ return null;
+ },
+ );
+
+ strategy = createStrategy();
+
+ try {
+ await strategy.validate(payload as JwtPayload);
+ throw new Error('Expected validate to reject');
+ } catch (e) {
+ expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION);
+ }
+ });
});
describe('Impersonation validation', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
index 20cf479486..a84d5a8bf8 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
@@ -363,25 +363,59 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
expectedWorkspaceId: workspace.id,
});
- if (isDefined(userContext)) {
- context.user = userContext.user;
- context.userWorkspace = userContext.userWorkspace;
- context.userWorkspaceId = userContext.userWorkspace.id;
+ assertIsDefinedOrThrow(
+ userContext,
+ new AuthException(
+ 'User or user workspace not found',
+ AuthExceptionCode.USER_NOT_FOUND,
+ {
+ userFriendlyMessage: msg`User does not have access to this workspace`,
+ },
+ ),
+ );
- const { flatWorkspaceMemberMaps } =
- await this.workspaceCacheService.getOrRecompute(workspace.id, [
- 'flatWorkspaceMemberMaps',
- ]);
+ context.user = userContext.user;
+ context.userWorkspace = userContext.userWorkspace;
+ context.userWorkspaceId = userContext.userWorkspace.id;
- const workspaceMemberId =
- flatWorkspaceMemberMaps.idByUserId[userContext.user.id];
-
- if (isDefined(workspaceMemberId)) {
- context.workspaceMemberId = workspaceMemberId;
- context.workspaceMember =
- flatWorkspaceMemberMaps.byId[workspaceMemberId];
- }
+ if (
+ workspace.activationStatus ===
+ WorkspaceActivationStatus.PENDING_CREATION ||
+ workspace.activationStatus ===
+ WorkspaceActivationStatus.ONGOING_CREATION
+ ) {
+ return context;
}
+
+ const { flatWorkspaceMemberMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspace.id, [
+ 'flatWorkspaceMemberMaps',
+ ]);
+
+ const workspaceMemberId =
+ flatWorkspaceMemberMaps.idByUserId[userContext.user.id];
+
+ const cachedWorkspaceMember = isDefined(workspaceMemberId)
+ ? flatWorkspaceMemberMaps.byId[workspaceMemberId]
+ : undefined;
+
+ const workspaceMember = isDefined(cachedWorkspaceMember?.deletedAt)
+ ? undefined
+ : cachedWorkspaceMember;
+
+ assertIsDefinedOrThrow(
+ workspaceMember,
+ new AuthException(
+ 'User is not a member of the workspace',
+ AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ {
+ userFriendlyMessage: msg`User is not a member of the workspace.`,
+ },
+ ),
+ );
+
+ context.workspaceMemberId = workspaceMemberId;
+ context.workspaceMember = workspaceMember;
}
return context;
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts
index 5aca1fe678..6f034e4b84 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts
@@ -159,11 +159,13 @@ export class ToolRegistryService {
userId?: string;
userWorkspaceId?: string;
locale?: keyof typeof APP_LOCALES;
+ rolePermissionConfig?: RolePermissionConfig;
},
): Promise {
const context = this.buildContextFromToolContext({
workspaceId,
roleId,
+ rolePermissionConfig: options?.rolePermissionConfig,
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
locale: options?.locale,
@@ -407,9 +409,10 @@ export class ToolRegistryService {
private buildContextFromToolContext(
context: ToolContext,
): ToolProviderContext {
- const rolePermissionConfig: RolePermissionConfig = {
- unionOf: [context.roleId],
- };
+ const rolePermissionConfig: RolePermissionConfig =
+ context.rolePermissionConfig ?? {
+ unionOf: [context.roleId],
+ };
return {
workspaceId: context.workspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts
index 73664e7d64..f884ceeb2c 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts
@@ -4,10 +4,12 @@ import { type APP_LOCALES } from 'twenty-shared/translations';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
+import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolContext = {
workspaceId: string;
roleId: string;
+ rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
userId?: string;
diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
index 42f8d8d84e..e23d4283f0 100644
--- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts
@@ -435,6 +435,24 @@ export class UserWorkspaceService {
};
}
+ async getUserWorkspaceForUser({
+ userId,
+ workspaceId,
+ relations = ['twoFactorAuthenticationMethods'],
+ }: {
+ userId: string;
+ workspaceId: string;
+ relations?: string[];
+ }): Promise {
+ return this.userWorkspaceRepository.findOne({
+ where: {
+ userId,
+ workspaceId,
+ },
+ relations,
+ });
+ }
+
async getUserWorkspaceForUserOrThrow({
userId,
workspaceId,
@@ -444,11 +462,9 @@ export class UserWorkspaceService {
workspaceId: string;
relations?: string[];
}): Promise {
- const userWorkspace = await this.userWorkspaceRepository.findOne({
- where: {
- userId,
- workspaceId,
- },
+ const userWorkspace = await this.getUserWorkspaceForUser({
+ userId,
+ workspaceId,
relations,
});
@@ -459,13 +475,13 @@ export class UserWorkspaceService {
return userWorkspace;
}
- async getWorkspaceMemberOrThrow({
+ async getWorkspaceMember({
workspaceMemberId,
workspaceId,
}: {
workspaceMemberId: string;
workspaceId: string;
- }): Promise {
+ }): Promise {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
@@ -477,22 +493,35 @@ export class UserWorkspaceService {
{ shouldBypassPermissionChecks: true },
);
- const workspaceMember = await workspaceMemberRepository.findOne({
+ return workspaceMemberRepository.findOne({
where: {
id: workspaceMemberId,
},
});
-
- if (!isDefined(workspaceMember)) {
- throw new Error('Workspace member not found');
- }
-
- return workspaceMember;
},
authContext,
);
}
+ async getWorkspaceMemberOrThrow({
+ workspaceMemberId,
+ workspaceId,
+ }: {
+ workspaceMemberId: string;
+ workspaceId: string;
+ }): Promise {
+ const workspaceMember = await this.getWorkspaceMember({
+ workspaceMemberId,
+ workspaceId,
+ });
+
+ if (!isDefined(workspaceMember)) {
+ throw new Error('Workspace member not found');
+ }
+
+ return workspaceMember;
+ }
+
private async computeDefaultAvatarUrl(
userId: string,
workspaceId: string,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts
index 9b9ec2db93..f2ef55c39e 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts
@@ -12,6 +12,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
+import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
@@ -55,6 +56,7 @@ import { AgentRunService } from './services/agent-run.service';
providers: [
AgentAsyncExecutorService,
AgentActorContextService,
+ AiGraphqlApiExceptionInterceptor,
AgentMessagePartResolver,
AgentRunResolver,
AgentRunService,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
index 6094b84f21..400a537d09 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
@@ -9,10 +9,13 @@ import {
IsOptional,
IsString,
ValidateNested,
+ IsUUID,
} from 'class-validator';
import { RunAgentMessageInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input';
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
@InputType('RunAgentInput')
export class RunAgentInputDTO {
@IsString()
@@ -26,6 +29,11 @@ export class RunAgentInputDTO {
@Field({ nullable: true })
prompt?: string;
+ @IsUUID()
+ @IsOptional()
+ @Field(() => UUIDScalarType, { nullable: true })
+ runAsWorkspaceMemberId?: string;
+
@IsOptional()
@IsArray()
@ArrayNotEmpty()
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts
index 245b8aaedb..4a9059e9b6 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/resolvers/agent-run.resolver.ts
@@ -1,19 +1,24 @@
-import { UseGuards } from '@nestjs/common';
+import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
+import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
+import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
+import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RunAgentInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input';
import { RunAgentResultDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-result.dto';
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
+import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
+@UseInterceptors(AiGraphqlApiExceptionInterceptor)
@MetadataResolver()
export class AgentRunResolver {
constructor(private readonly agentRunService: AgentRunService) {}
@@ -24,10 +29,16 @@ export class AgentRunResolver {
@AuthWorkspace() workspace: FlatWorkspace,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
+ @AuthApplication({ allowUndefined: true })
+ callerApplication: FlatApplication | undefined,
+ @AuthWorkspaceMemberId()
+ workspaceMemberId: string | undefined,
): Promise {
return this.agentRunService.run({
workspace,
requestUserWorkspaceId: userWorkspaceId ?? null,
+ requestWorkspaceMemberId: workspaceMemberId ?? null,
+ callerApplication,
input,
});
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts
new file mode 100644
index 0000000000..76a846be96
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-actor-context.service.spec.ts
@@ -0,0 +1,133 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+
+import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
+import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception';
+import {
+ PermissionsException,
+ PermissionsExceptionCode,
+} from 'src/engine/metadata-modules/permissions/permissions.exception';
+import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
+import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
+
+describe('AgentActorContextService', () => {
+ let service: AgentActorContextService;
+ let userWorkspaceService: {
+ getWorkspaceMember: jest.Mock;
+ getUserWorkspaceForUser: jest.Mock;
+ };
+ let userRoleService: { getRoleIdForUserWorkspace: jest.Mock };
+
+ const workspaceId = 'workspace-1';
+ const workspaceMemberId = 'workspace-member-1';
+
+ const runAsArgs = { workspaceMemberId, workspaceId };
+
+ beforeEach(async () => {
+ userWorkspaceService = {
+ getWorkspaceMember: jest.fn().mockResolvedValue({
+ id: workspaceMemberId,
+ userId: 'user-1',
+ name: { firstName: 'Ada', lastName: 'Lovelace' },
+ }),
+ getUserWorkspaceForUser: jest.fn().mockResolvedValue({
+ id: 'user-workspace-1',
+ userId: 'user-1',
+ workspace: {
+ id: workspaceId,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ },
+ user: {
+ id: 'user-1',
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ },
+ }),
+ };
+ userRoleService = {
+ getRoleIdForUserWorkspace: jest.fn().mockResolvedValue('role-1'),
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ AgentActorContextService,
+ { provide: UserWorkspaceService, useValue: userWorkspaceService },
+ { provide: UserRoleService, useValue: userRoleService },
+ { provide: GlobalWorkspaceOrmManager, useValue: {} },
+ ],
+ }).compile();
+
+ service = module.get(AgentActorContextService);
+ });
+
+ it('reports a not-found error when the workspace member does not exist', async () => {
+ userWorkspaceService.getWorkspaceMember.mockResolvedValue(null);
+
+ await expect(
+ service.buildRunAsWorkspaceMemberContext(runAsArgs),
+ ).rejects.toMatchObject({
+ code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ });
+ });
+
+ it('reports a not-found error when the member has no user workspace', async () => {
+ userWorkspaceService.getUserWorkspaceForUser.mockResolvedValue(null);
+
+ await expect(
+ service.buildRunAsWorkspaceMemberContext(runAsArgs),
+ ).rejects.toMatchObject({
+ code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ });
+ });
+
+ it('reports a not-found error when the member has no role assigned', async () => {
+ userRoleService.getRoleIdForUserWorkspace.mockRejectedValue(
+ new PermissionsException(
+ 'No role found for userWorkspace',
+ PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
+ ),
+ );
+
+ await expect(
+ service.buildRunAsWorkspaceMemberContext(runAsArgs),
+ ).rejects.toMatchObject({
+ code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ });
+ });
+
+ it('lets an infrastructure failure keep its own error type', async () => {
+ const databaseError = new Error('connection terminated unexpectedly');
+
+ userWorkspaceService.getWorkspaceMember.mockRejectedValue(databaseError);
+
+ await expect(
+ service.buildRunAsWorkspaceMemberContext(runAsArgs),
+ ).rejects.toBe(databaseError);
+ });
+
+ it('lets an unrelated permissions failure keep its own error type', async () => {
+ const permissionsError = new PermissionsException(
+ 'Permission denied',
+ PermissionsExceptionCode.PERMISSION_DENIED,
+ );
+
+ userRoleService.getRoleIdForUserWorkspace.mockRejectedValue(
+ permissionsError,
+ );
+
+ await expect(
+ service.buildRunAsWorkspaceMemberContext(runAsArgs),
+ ).rejects.toBe(permissionsError);
+ });
+
+ it('returns the member actor context, auth context and role', async () => {
+ const result = await service.buildRunAsWorkspaceMemberContext(runAsArgs);
+
+ expect(result.roleId).toBe('role-1');
+ expect(result.authContext).toMatchObject({
+ type: 'user',
+ workspaceMemberId,
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
index 1937dee05a..e62fbb92ec 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
@@ -211,6 +211,49 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
expect(system).not.toContain('create_one_workflow');
});
+ it('intersects the agent role with the run-as role on the lazy path used by runAgent', async () => {
+ roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
+ toolRegistry.buildToolIndex.mockResolvedValueOnce([]);
+
+ await service.executeAgent({
+ agent: buildAgent(),
+ messages: [{ role: 'user', content: 'test' }],
+ baseSystemPrompt: 'base system prompt',
+ workspaceId,
+ runAsRoleId: 'run-as-role-id',
+ toolLoadingStrategy: 'lazy',
+ });
+
+ expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith(
+ workspaceId,
+ agentRoleId,
+ expect.objectContaining({
+ rolePermissionConfig: {
+ intersectionOf: [agentRoleId, 'run-as-role-id'],
+ },
+ }),
+ );
+ });
+
+ it('leaves the lazy path on its default role resolution without a run-as role', async () => {
+ roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
+ toolRegistry.buildToolIndex.mockResolvedValueOnce([]);
+
+ await service.executeAgent({
+ agent: buildAgent(),
+ messages: [{ role: 'user', content: 'test' }],
+ baseSystemPrompt: 'base system prompt',
+ workspaceId,
+ toolLoadingStrategy: 'lazy',
+ });
+
+ expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith(
+ workspaceId,
+ agentRoleId,
+ expect.objectContaining({ rolePermissionConfig: undefined }),
+ );
+ });
+
it('does not resolve registry tools when the agent has no role (fail-closed)', async () => {
roleTargetRepository.findOne.mockResolvedValueOnce(null);
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
index a3a5d7fc3b..f5478c82f3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
@@ -2,10 +2,16 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
+import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
+import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
+import {
+ AiException,
+ AiExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai.exception';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
describe('AgentRunService', () => {
@@ -13,6 +19,9 @@ describe('AgentRunService', () => {
let agentRepository: { findOne: jest.Mock };
let applicationService: { findById: jest.Mock };
let agentAsyncExecutorService: { executeAgent: jest.Mock };
+ let agentActorContextService: {
+ buildRunAsWorkspaceMemberContext: jest.Mock;
+ };
const workspace = { id: 'workspace-1' } as FlatWorkspace;
@@ -23,6 +32,22 @@ describe('AgentRunService', () => {
prompt: 'Enrich record 123',
};
+ const callerApplication = { id: 'app-1' } as FlatApplication;
+
+ const runAsActorContext = {
+ source: 'AGENT',
+ workspaceMemberId: 'workspace-member-1',
+ };
+
+ const runAsAuthContext = {
+ type: 'user',
+ workspace,
+ userWorkspaceId: 'member-user-workspace-1',
+ user: { id: 'user-1' },
+ workspaceMemberId: 'workspace-member-1',
+ workspaceMember: { id: 'workspace-member-1' },
+ };
+
beforeEach(async () => {
agentRepository = { findOne: jest.fn().mockResolvedValue(agent) };
applicationService = {
@@ -34,10 +59,21 @@ describe('AgentRunService', () => {
hasNoMoreAvailableCredits: false,
}),
};
+ agentActorContextService = {
+ buildRunAsWorkspaceMemberContext: jest.fn().mockResolvedValue({
+ actorContext: runAsActorContext,
+ authContext: runAsAuthContext,
+ roleId: 'member-role-1',
+ }),
+ };
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentRunService,
+ {
+ provide: AgentActorContextService,
+ useValue: agentActorContextService,
+ },
{
provide: AgentAsyncExecutorService,
useValue: agentAsyncExecutorService,
@@ -60,6 +96,7 @@ describe('AgentRunService', () => {
const result = await service.run({
workspace,
requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
input,
});
@@ -82,6 +119,7 @@ describe('AgentRunService', () => {
await service.run({
workspace,
requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
input,
});
@@ -97,10 +135,30 @@ describe('AgentRunService', () => {
);
});
+ it('does not resolve a run-as context when no workspace member is requested', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
+ input,
+ });
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ actorContext: undefined,
+ runAsRoleId: undefined,
+ }),
+ );
+ });
+
it('converts a prompt into a single user message', async () => {
await service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input,
});
@@ -109,6 +167,54 @@ describe('AgentRunService', () => {
).toEqual([{ role: 'user', content: input.prompt }]);
});
+ it('runs as the requested workspace member with their auth context and role', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ });
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).toHaveBeenCalledWith({
+ workspaceMemberId: 'workspace-member-1',
+ workspaceId: workspace.id,
+ });
+ expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ actorContext: runAsActorContext,
+ authContext: runAsAuthContext,
+ runAsRoleId: 'member-role-1',
+ userWorkspaceId: 'member-user-workspace-1',
+ }),
+ );
+ });
+
+ it('fails the run instead of falling back to the agent role when the member cannot be resolved', async () => {
+ agentActorContextService.buildRunAsWorkspaceMemberContext.mockRejectedValue(
+ new AiException(
+ 'Workspace member not found',
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ ),
+ );
+
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ }),
+ ).rejects.toMatchObject({
+ code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ });
+
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
it('passes messages to the executor when messages are provided instead of prompt', async () => {
const messages = [
{ role: 'user' as const, content: 'Hello' },
@@ -119,6 +225,7 @@ describe('AgentRunService', () => {
await service.run({
workspace,
requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
input: {
agentUniversalIdentifier: 'agent-uid',
messages,
@@ -145,6 +252,7 @@ describe('AgentRunService', () => {
service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input: {
agentUniversalIdentifier: 'agent-uid',
},
@@ -155,11 +263,124 @@ describe('AgentRunService', () => {
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
});
+ it('rejects running as a workspace member when the caller is not an application', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ }),
+ ).rejects.toThrow('requires an application access token');
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
+ it('rejects an application token issued for a user naming a different member', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: 'workspace-member-2',
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ }),
+ ).rejects.toThrow('can only run an agent as that user');
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
+ it('rejects a user-bound application token whose member identity never resolved', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ }),
+ ).rejects.toThrow('can only run an agent as that user');
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
+ it('allows an application token with no user binding to name any member', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'someone-elses-member-id' },
+ });
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).toHaveBeenCalledWith({
+ workspaceMemberId: 'someone-elses-member-id',
+ workspaceId: workspace.id,
+ });
+ });
+
+ it('rejects running an agent that belongs to another application', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
+ callerApplication: { id: 'another-app' } as FlatApplication,
+ input,
+ }),
+ ).rejects.toMatchObject({
+ code: AiExceptionCode.RUN_AGENT_NOT_ALLOWED,
+ });
+
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
+ it('allows an application to run its own agent', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
+ callerApplication,
+ input,
+ });
+
+ expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalled();
+ });
+
+ it('allows an application token issued for a user to run as that same user', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: 'workspace-member-1',
+ callerApplication,
+ input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
+ });
+
+ expect(
+ agentActorContextService.buildRunAsWorkspaceMemberContext,
+ ).toHaveBeenCalledWith({
+ workspaceMemberId: 'workspace-member-1',
+ workspaceId: workspace.id,
+ });
+ });
+
it('throws when both prompt and messages are provided', async () => {
await expect(
service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input: {
agentUniversalIdentifier: 'agent-uid',
prompt: 'Enrich record 123',
@@ -176,6 +397,7 @@ describe('AgentRunService', () => {
await service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input,
});
@@ -196,6 +418,7 @@ describe('AgentRunService', () => {
const result = await service.run({
workspace,
requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
input,
});
@@ -214,6 +437,7 @@ describe('AgentRunService', () => {
const result = await service.run({
workspace,
requestUserWorkspaceId: 'user-workspace-1',
+ requestWorkspaceMemberId: null,
input,
});
@@ -231,6 +455,7 @@ describe('AgentRunService', () => {
service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input,
}),
).rejects.toThrow(/not found/);
@@ -246,6 +471,7 @@ describe('AgentRunService', () => {
service.run({
workspace,
requestUserWorkspaceId: null,
+ requestWorkspaceMemberId: null,
input,
}),
).rejects.toThrow(/not found/);
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts
index ae26471471..c021a2721c 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts
@@ -1,13 +1,22 @@
import { Injectable } from '@nestjs/common';
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
+import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
+import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
+import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type';
+import {
+ PermissionsException,
+ PermissionsExceptionCode,
+} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -113,4 +122,89 @@ export class AgentActorContextService {
userContext,
};
}
+
+ async buildRunAsWorkspaceMemberContext({
+ workspaceMemberId,
+ workspaceId,
+ }: {
+ workspaceMemberId: string;
+ workspaceId: string;
+ }): Promise {
+ const workspaceMember = await this.userWorkspaceService.getWorkspaceMember({
+ workspaceMemberId,
+ workspaceId,
+ });
+
+ if (!isDefined(workspaceMember)) {
+ throw new AiException(
+ `Workspace member ${workspaceMemberId} not found`,
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ );
+ }
+
+ const userWorkspace =
+ await this.userWorkspaceService.getUserWorkspaceForUser({
+ userId: workspaceMember.userId,
+ workspaceId,
+ relations: ['workspace', 'user'],
+ });
+
+ if (!isDefined(userWorkspace)) {
+ throw new AiException(
+ `Workspace member ${workspaceMemberId} has no user workspace`,
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ );
+ }
+
+ const roleId = await this.resolveRoleIdOrThrow({
+ userWorkspaceId: userWorkspace.id,
+ workspaceId,
+ workspaceMemberId,
+ });
+
+ return {
+ actorContext: buildCreatedByFromFullNameMetadata({
+ fullNameMetadata: workspaceMember.name,
+ workspaceMemberId: workspaceMember.id,
+ source: FieldActorSource.AGENT,
+ }),
+ authContext: buildUserAuthContext({
+ workspace: fromWorkspaceEntityToFlat(userWorkspace.workspace),
+ userWorkspaceId: userWorkspace.id,
+ user: fromUserEntityToFlat(userWorkspace.user),
+ workspaceMemberId: workspaceMember.id,
+ workspaceMember,
+ }),
+ roleId,
+ };
+ }
+
+ private async resolveRoleIdOrThrow({
+ userWorkspaceId,
+ workspaceId,
+ workspaceMemberId,
+ }: {
+ userWorkspaceId: string;
+ workspaceId: string;
+ workspaceMemberId: string;
+ }): Promise {
+ try {
+ return await this.userRoleService.getRoleIdForUserWorkspace({
+ userWorkspaceId,
+ workspaceId,
+ });
+ } catch (error) {
+ if (
+ error instanceof PermissionsException &&
+ error.code === PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE
+ ) {
+ throw new AiException(
+ `Workspace member ${workspaceMemberId} has no role assigned`,
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
+ );
+ }
+
+ throw error;
+ }
+ }
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
index 7c51128857..fdfa2a65c6 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
@@ -47,6 +47,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import { type AgentToolLoadingStrategy } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type';
+import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -144,11 +145,13 @@ export class AgentAsyncExecutorService {
private async buildPreloadedRegistryTools({
agent,
agentRoleId,
+ runAsRoleId,
authContext,
actorContext,
}: {
agent: AgentEntity;
agentRoleId: string;
+ runAsRoleId?: string;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
}): Promise {
@@ -157,7 +160,10 @@ export class AgentAsyncExecutorService {
const toolProviderContext: ToolProviderContext = {
workspaceId: agent.workspaceId,
roleId: agentRoleId,
- rolePermissionConfig: { intersectionOf: [agentRoleId] },
+ rolePermissionConfig: buildAgentRolePermissionConfig({
+ agentRoleId,
+ runAsRoleId,
+ }),
requireExplicitObjectGrants: true,
authContext,
actorContext,
@@ -179,19 +185,26 @@ export class AgentAsyncExecutorService {
private async buildLazyRegistryTools({
agent,
agentRoleId,
+ runAsRoleId,
authContext,
actorContext,
}: {
agent: AgentEntity;
agentRoleId: string;
+ runAsRoleId?: string;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
}): Promise<{ tools: ToolSet; catalogSection: string }> {
const { userId, userWorkspaceId } = this.resolveUserIdentity(authContext);
+ const rolePermissionConfig = isDefined(runAsRoleId)
+ ? buildAgentRolePermissionConfig({ agentRoleId, runAsRoleId })
+ : undefined;
+
const toolContext: ToolContext = {
workspaceId: agent.workspaceId,
roleId: agentRoleId,
+ rolePermissionConfig,
authContext,
actorContext,
userId,
@@ -201,7 +214,7 @@ export class AgentAsyncExecutorService {
const fullCatalog = await this.toolRegistry.buildToolIndex(
agent.workspaceId,
agentRoleId,
- { userId, userWorkspaceId },
+ { userId, userWorkspaceId, rolePermissionConfig },
);
const allowedCategories = new Set(WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES);
@@ -244,6 +257,7 @@ export class AgentAsyncExecutorService {
authContext,
workspaceId,
userWorkspaceId,
+ runAsRoleId,
operationType = UsageOperationType.AI_WORKFLOW_TOKEN,
toolLoadingStrategy = 'preload',
}: {
@@ -254,6 +268,7 @@ export class AgentAsyncExecutorService {
authContext?: WorkspaceAuthContext;
workspaceId: string;
userWorkspaceId?: string | null;
+ runAsRoleId?: string;
operationType?: UsageOperationType;
toolLoadingStrategy?: AgentToolLoadingStrategy;
}): Promise {
@@ -317,6 +332,7 @@ export class AgentAsyncExecutorService {
const lazyToolset = await this.buildLazyRegistryTools({
agent,
agentRoleId,
+ runAsRoleId,
authContext,
actorContext,
});
@@ -327,6 +343,7 @@ export class AgentAsyncExecutorService {
registryTools = await this.buildPreloadedRegistryTools({
agent,
agentRoleId,
+ runAsRoleId,
authContext,
actorContext,
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
index 2f4c2b51b0..3c9a3fd597 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
@@ -5,13 +5,16 @@ import {
type RunAgentMessage,
type RunAgentResult,
} from 'twenty-shared/application';
-import { isNonEmptyArray } from 'twenty-shared/utils';
+import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
+import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
+import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
+import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import {
@@ -25,6 +28,7 @@ type RunAgentServiceInput = {
agentUniversalIdentifier: string;
prompt?: string | null;
messages?: RunAgentMessage[] | null;
+ runAsWorkspaceMemberId?: string;
};
@Injectable()
@@ -32,6 +36,7 @@ export class AgentRunService {
private readonly logger = new Logger(AgentRunService.name);
constructor(
+ private readonly agentActorContextService: AgentActorContextService,
private readonly agentAsyncExecutorService: AgentAsyncExecutorService,
private readonly applicationService: ApplicationService,
@InjectWorkspaceScopedRepository(AgentEntity)
@@ -41,10 +46,14 @@ export class AgentRunService {
async run({
workspace,
requestUserWorkspaceId,
+ requestWorkspaceMemberId,
+ callerApplication,
input,
}: {
workspace: FlatWorkspace;
requestUserWorkspaceId: string | null;
+ requestWorkspaceMemberId: string | null;
+ callerApplication?: FlatApplication;
input: RunAgentServiceInput;
}): Promise {
const prompt = input.prompt;
@@ -73,6 +82,16 @@ export class AgentRunService {
);
}
+ if (
+ isDefined(callerApplication) &&
+ agent.applicationId !== callerApplication.id
+ ) {
+ throw new AiException(
+ `Agent ${input.agentUniversalIdentifier} belongs to another application`,
+ AiExceptionCode.RUN_AGENT_NOT_ALLOWED,
+ );
+ }
+
const application = await this.applicationService.findById(
agent.applicationId,
);
@@ -83,7 +102,15 @@ export class AgentRunService {
);
}
- const authContext: WorkspaceAuthContext = {
+ const runAsContext = await this.resolveRunAsContext({
+ runAsWorkspaceMemberId: input.runAsWorkspaceMemberId,
+ callerApplication,
+ requestUserWorkspaceId,
+ requestWorkspaceMemberId,
+ workspaceId: workspace.id,
+ });
+
+ const authContext: WorkspaceAuthContext = runAsContext?.authContext ?? {
type: 'application',
workspace,
application,
@@ -95,9 +122,12 @@ export class AgentRunService {
agent,
messages,
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
+ actorContext: runAsContext?.actorContext,
authContext,
workspaceId: workspace.id,
- userWorkspaceId: requestUserWorkspaceId,
+ userWorkspaceId:
+ runAsContext?.authContext.userWorkspaceId ?? requestUserWorkspaceId,
+ runAsRoleId: runAsContext?.roleId,
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
toolLoadingStrategy: 'lazy',
},
@@ -129,4 +159,44 @@ export class AgentRunService {
};
}
}
+
+ private async resolveRunAsContext({
+ runAsWorkspaceMemberId,
+ callerApplication,
+ requestUserWorkspaceId,
+ requestWorkspaceMemberId,
+ workspaceId,
+ }: {
+ runAsWorkspaceMemberId?: string;
+ callerApplication?: FlatApplication;
+ requestUserWorkspaceId: string | null;
+ requestWorkspaceMemberId: string | null;
+ workspaceId: string;
+ }): Promise {
+ if (!isDefined(runAsWorkspaceMemberId)) {
+ return undefined;
+ }
+
+ if (!isDefined(callerApplication)) {
+ throw new AiException(
+ 'Running an agent as a workspace member requires an application access token',
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED,
+ );
+ }
+
+ if (
+ isDefined(requestUserWorkspaceId) &&
+ requestWorkspaceMemberId !== runAsWorkspaceMemberId
+ ) {
+ throw new AiException(
+ 'An application token issued for a user can only run an agent as that user',
+ AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED,
+ );
+ }
+
+ return this.agentActorContextService.buildRunAsWorkspaceMemberContext({
+ workspaceMemberId: runAsWorkspaceMemberId,
+ workspaceId,
+ });
+ }
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts
new file mode 100644
index 0000000000..1a5cf6eb3f
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type.ts
@@ -0,0 +1,9 @@
+import { type ActorMetadata } from 'twenty-shared/types';
+
+import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
+
+export type RunAsWorkspaceMemberContext = {
+ actorContext: ActorMetadata;
+ authContext: UserWorkspaceAuthContext;
+ roleId: string;
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts
new file mode 100644
index 0000000000..c313e843cb
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/build-agent-role-permission-config.util.spec.ts
@@ -0,0 +1,27 @@
+import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util';
+
+describe('buildAgentRolePermissionConfig', () => {
+ it('keeps the agent role alone when there is no run-as role', () => {
+ expect(
+ buildAgentRolePermissionConfig({ agentRoleId: 'agent-role-id' }),
+ ).toEqual({ intersectionOf: ['agent-role-id'] });
+ });
+
+ it('intersects with the run-as role, agent role first', () => {
+ expect(
+ buildAgentRolePermissionConfig({
+ agentRoleId: 'agent-role-id',
+ runAsRoleId: 'run-as-role-id',
+ }),
+ ).toEqual({ intersectionOf: ['agent-role-id', 'run-as-role-id'] });
+ });
+
+ it('does not repeat the role when the member already has the agent role', () => {
+ expect(
+ buildAgentRolePermissionConfig({
+ agentRoleId: 'agent-role-id',
+ runAsRoleId: 'agent-role-id',
+ }),
+ ).toEqual({ intersectionOf: ['agent-role-id'] });
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts
new file mode 100644
index 0000000000..59396fc255
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util.ts
@@ -0,0 +1,17 @@
+import { isDefined } from 'twenty-shared/utils';
+
+import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
+
+export const buildAgentRolePermissionConfig = ({
+ agentRoleId,
+ runAsRoleId,
+}: {
+ agentRoleId: string;
+ runAsRoleId?: string;
+}): RolePermissionConfig => {
+ if (!isDefined(runAsRoleId) || runAsRoleId === agentRoleId) {
+ return { intersectionOf: [agentRoleId] };
+ }
+
+ return { intersectionOf: [agentRoleId, runAsRoleId] };
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts
index 1fa1ccb9dc..b89b1c0d12 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts
@@ -21,6 +21,9 @@ export enum AiExceptionCode {
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
+ RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED = 'RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED',
+ RUN_AS_WORKSPACE_MEMBER_NOT_FOUND = 'RUN_AS_WORKSPACE_MEMBER_NOT_FOUND',
+ RUN_AGENT_NOT_ALLOWED = 'RUN_AGENT_NOT_ALLOWED',
NO_FAILED_TURN_TO_RETRY = 'NO_FAILED_TURN_TO_RETRY',
STREAM_INTERRUPTED = 'STREAM_INTERRUPTED',
}
@@ -59,6 +62,12 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
return msg`Role not found.`;
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
return msg`This role cannot be assigned to agents.`;
+ case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED:
+ return msg`This action is not available for your request.`;
+ case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND:
+ return msg`Workspace member not found.`;
+ case AiExceptionCode.RUN_AGENT_NOT_ALLOWED:
+ return msg`This action is not available for your request.`;
case AiExceptionCode.NO_FAILED_TURN_TO_RETRY:
return msg`There is no failed message to retry.`;
case AiExceptionCode.STREAM_INTERRUPTED:
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts
index 30411ef6e0..be5eea6beb 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts
@@ -26,6 +26,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
case AiExceptionCode.WORKSPACE_NOT_FOUND:
case AiExceptionCode.MESSAGE_NOT_FOUND:
case AiExceptionCode.ROLE_NOT_FOUND:
+ case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND:
throw new NotFoundError(error);
case AiExceptionCode.CONTEXT_WINDOW_EXCEEDED:
case AiExceptionCode.INVALID_AGENT_INPUT:
@@ -38,6 +39,8 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
throw new ConflictError(error);
case AiExceptionCode.AGENT_IS_STANDARD:
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
+ case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED:
+ case AiExceptionCode.RUN_AGENT_NOT_ALLOWED:
throw new ForbiddenError(error);
case AiExceptionCode.AGENT_EXECUTION_FAILED:
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
diff --git a/packages/twenty-shared/src/application/runAgentType.ts b/packages/twenty-shared/src/application/runAgentType.ts
index 3f93cf542e..a811710100 100644
--- a/packages/twenty-shared/src/application/runAgentType.ts
+++ b/packages/twenty-shared/src/application/runAgentType.ts
@@ -7,6 +7,7 @@ export type RunAgentMessage = {
export type RunAgentInput = {
agentUniversalIdentifier: string;
+ runAsWorkspaceMemberId?: string;
} & (
| { prompt: string; messages?: never }
| { messages: RunAgentMessage[]; prompt?: never }